feat: add pipeline flow

This commit is contained in:
2026-10-05 01:31:38 +05:00
parent e87bc1d715
commit 6b946bfb08
12 changed files with 716 additions and 177 deletions
+25 -13
View File
@@ -4,15 +4,6 @@
> находки — `docs/plan-seo.md`, `docs/plan-platform.md`, > находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
> `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`. > `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
## UX — приоритетная очередь
Порядок согласован: D (pipeline). Делать по одной, сверху вниз.
- [ ] **D. Pipeline** — процесс редактирования и настройки: drag-n-drop,
удаление, добавление инструментов; переход «один инструмент ↔ pipeline»
без перезагрузки. Следом — идеи 1–3 (смена первого инструмента,
сворачивание шагов, несколько цепочек)
## lint и прочие правила ## lint и прочие правила
- [ ] `tseslint.config(...)` deprecated: сигнатура - [ ] `tseslint.config(...)` deprecated: сигнатура
@@ -25,6 +16,13 @@
## Мелочи всякие ## Мелочи всякие
- [ ] названия инструментов - сейчас в pipeline инструмент называется "обрезать
png", но он работает с любыми картинками, наверное надо убрать. но в
заголовке - возможно надо все форматы указать (SEO задача?)
- [ ] кнопка добавить инструмент в pipeline - обдумать селектор с номальным
поиском, фильтром возможных инструментов и т.д.
- [ ] pipeline - промежуточные результаты
- [ ] pipeline - drag-n-drop инструментов
- [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для - [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
нарезки ассетов нарезки ассетов
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла - [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
@@ -37,21 +35,30 @@
- [ ] e2e webkit: случайные падения по таймауту 5с (элемент не появляется), - [ ] e2e webkit: случайные падения по таймауту 5с (элемент не появляется),
каждый прогон падают разные тесты — webkit под Windows слишком медленный. каждый прогон падают разные тесты — webkit под Windows слишком медленный.
Рассмотреть увеличение timeout/expectTimeout для webkit-проекта в Рассмотреть увеличение timeout/expectTimeout для webkit-проекта в
`playwright.config.ts` `playwright.config.ts`. После фикса перепроверить `tool-flows.spec.ts`
«chain: remove step» — на webkit удаление шага не успевает за 5с (на
остальных движках стабильно)
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его - [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
ничем не читают. Либо показывать подсказку для инструментов с пустой ничем не читают. Либо показывать подсказку для инструментов с пустой
схемой (FA-E1), либо удалить ключ из обеих локалей схемой (FA-E1), либо удалить ключ из обеих локалей
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы, - [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
привязанные к разметке или svelte-классам (сейчас остались: привязанные к разметке или svelte-классам (сейчас остались:
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`) `tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
правило такое (запретить toContain без i18n)
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри - [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор - [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
какой размер и 2 сетки) какой размер - 112\56\28px или 72\36\18)
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
файлов, посмотреть реализацию в
[https://github.com/tma02/twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить - [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] можно ли в тестах получать i18n ключ вместо текста? - [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д. и т.д., сделать lint правило
- [ ] комментарии на английском, lint правило
- [ ] уменьшить количество и многословность комментариев (lint правило?)
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность - [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем должны быть настраиваемой 0-100%, а не переключателем
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в - [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
@@ -89,6 +96,11 @@
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть (`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
в дизайне, а где должен быть 0 в дизайне, а где должен быть 0
## Инструменты
- [ ] qr code to text, text to qr code
- [ ] bar code to text, text to bar code
## Переезд ## Переезд
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) — - [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
+37
View File
@@ -263,6 +263,43 @@ test("runs without Web Worker (fallback)", async ({ browser }, testInfo) => {
} }
}); });
test("chain: add step extends pipeline without reload and persists", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
await page.getByRole("button", { name: "Add step" }).last().click();
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
const stepTwo = page.getByRole("heading", {
name: "Grayscale PNG",
exact: true,
});
await expect(stepTwo).toBeVisible();
await expect(resultImage(page)).toBeVisible();
// цепочка переживает перезагрузку (localStorage)
await page.reload();
await expect(stepTwo).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("chain: remove step returns to a single tool", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await page.getByRole("button", { name: "Add step" }).last().click();
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
const stepTwo = page.getByRole("heading", {
name: "Grayscale PNG",
exact: true,
});
await expect(stepTwo).toBeVisible();
await page.getByRole("button", { name: "Remove step" }).last().click();
await expect(stepTwo).toHaveCount(0);
expectNoErrors(sink);
});
test("invalid file upload shows error, no crash", async ({ page }) => { test("invalid file upload shows error, no crash", async ({ page }) => {
const sink = trackErrors(page); const sink = trackErrors(page);
await openTool(page, "flip-png"); await openTool(page, "flip-png");
-3
View File
@@ -155,9 +155,6 @@
--size-tool-icon: 42px; --size-tool-icon: 42px;
--size-tool-min-height: 106px; --size-tool-min-height: 106px;
--size-tool-arrow: 18px; --size-tool-arrow: 18px;
--size-step-min-height: 138px;
--size-step-index: 46px;
--size-step-grip: 20px;
--size-card-min: 240px; --size-card-min: 240px;
--size-card-min-narrow: 200px; --size-card-min-narrow: 200px;
--size-parts-grid-min: 120px; --size-parts-grid-min: 120px;
+54 -30
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Ellipsis, GripVertical, X } from "@lucide/svelte"; import { GripVertical, X } from "@lucide/svelte";
import IconButton from "$lib/components/ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
@@ -8,63 +8,87 @@
index: number; index: number;
title?: string; title?: string;
type?: string; type?: string;
draggable?: boolean;
tools?: Snippet; tools?: Snippet;
children?: Snippet; children?: Snippet;
onremove?: () => void; onremove?: () => void;
ondragstart?: (e: DragEvent) => void;
ondragover?: (e: DragEvent) => void;
ondrop?: (e: DragEvent) => void;
ondragend?: () => void;
} }
let { index, title, type, tools, children, onremove }: Props = $props(); let {
index,
title,
type,
draggable = false,
tools,
children,
onremove,
ondragstart,
ondragover,
ondrop,
ondragend,
}: Props = $props();
</script> </script>
<article class="step-card"> <article
<div class="step-index">{index.toString().padStart(2, "0")}</div> class="step-card"
{draggable}
ondragstart={(e) => ondragstart?.(e)}
ondragover={(e) => ondragover?.(e)}
ondrop={(e) => ondrop?.(e)}
ondragend={() => ondragend?.()}
>
<header class="step-head">
<span class="step-index">{index.toString().padStart(2, "0")}</span>
<GripVertical size={16} class="drag" aria-hidden="true" /> <GripVertical size={16} class="drag" aria-hidden="true" />
<div class="step-body">
<div class="step-heading">
<div class="step-heading-text"> <div class="step-heading-text">
{#if type}<span class="step-type">{type}</span>{/if} {#if type}<span class="step-type">{type}</span>{/if}
{#if title}<h2 class="step-title">{title}</h2>{/if} {#if title}<h2 class="step-title">{title}</h2>{/if}
</div> </div>
<div class="step-tools"> <div class="step-tools">
{#if tools}{@render tools()}{/if} {#if tools}{@render tools()}{/if}
{#if onremove}
<IconButton <IconButton
icon={X} icon={X}
label={t("chain.removeStepAria")} label={t("chain.removeStepAria")}
variant="clear" variant="clear"
size="s" size="s"
onclick={() => onremove?.()} onclick={() => onremove()}
/> />
<Ellipsis size={17} />
</div>
</div>
{#if children}
{@render children()}
{/if} {/if}
</div> </div>
</header>
{#if children}
<div class="step-body">
{@render children()}
</div>
{/if}
</article> </article>
<style> <style>
.step-card { .step-card {
display: grid;
grid-template-columns: var(--size-step-index) var(--size-step-grip) 1fr;
min-height: var(--size-step-min-height);
border: var(--size-border) solid var(--color-border); border: var(--size-border) solid var(--color-border);
background: var(--color-panel); background: var(--color-panel);
} }
.step-head {
display: flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-xl);
border-bottom: var(--size-border) solid var(--color-border);
}
.step-index { .step-index {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
color: var(--color-main); color: var(--color-main);
padding: var(--space-xl) 0 0px var(--space-xl);
} }
.step-body { .step-heading-text {
padding: var(--space-xl) var(--space-xl) var(--space-xl); flex: 1;
}
.step-heading {
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: space-between; gap: var(--space-m);
gap: var(--space-xl);
border-color: var(--color-border);
} }
.step-type { .step-type {
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -72,7 +96,7 @@
letter-spacing: var(--space-text-xl); letter-spacing: var(--space-text-xl);
} }
.step-title { .step-title {
margin: var(--space-s) 0 var(--space-xl); margin: 0;
font: 600 var(--font-size-m) var(--font-mono); font: 600 var(--font-size-m) var(--font-mono);
color: var(--color-text); color: var(--color-text);
} }
@@ -81,11 +105,11 @@
align-items: center; align-items: center;
gap: var(--space-l); gap: var(--space-l);
} }
.step-card :global(.drag) { .step-body {
margin-top: var(--space-xl); padding: var(--space-l) var(--space-xl) var(--space-xl);
color: var(--color-border);
} }
.step-body > :not(.step-heading) { .step-card :global(.drag) {
padding: var(--space-l); color: var(--color-border);
cursor: grab;
} }
</style> </style>
@@ -0,0 +1,123 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { chainablePages } from "$lib/pipeline.svelte";
import type { Page } from "$lib/registry";
import { Plus } from "@lucide/svelte";
interface Props {
onadd: (toolId: string) => void;
}
let { onadd }: Props = $props();
const pages = chainablePages();
let open = $state(false);
let root = $state<HTMLDivElement | null>(null);
function choose(page: Page) {
onadd(page.steps[0].id);
open = false;
}
$effect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!root?.contains(e.target as Node)) open = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
});
</script>
<div class="add-step" bind:this={root}>
<button
type="button"
class="add-btn"
aria-haspopup="menu"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Icon icon={Plus} size={14} />
<span>{t("chain.addStep")}</span>
</button>
{#if open}
<div class="menu" role="menu">
{#each pages as page (page.slug)}
<button
type="button"
class="item"
role="menuitem"
onclick={() => choose(page)}
>
{pageTitle(page)}
</button>
{/each}
</div>
{/if}
</div>
<style>
.add-step {
position: relative;
display: flex;
justify-content: flex-end;
margin: var(--space-s) 0;
}
.add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-main);
border-radius: var(--radius-m);
background: var(--color-main);
color: var(--color-background);
font: 600 var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add-btn:hover {
filter: brightness(1.1);
}
.menu {
position: absolute;
top: calc(100% + var(--space-s));
right: 0;
z-index: var(--z-dropdown);
min-width: calc(var(--space-xxxl) * 8);
max-height: calc(var(--space-xxxl) * 8);
overflow-y: auto;
display: grid;
gap: var(--space-s);
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
}
.item {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item:hover {
color: var(--color-text);
border-color: var(--color-border);
}
</style>
@@ -98,7 +98,6 @@
size="s" size="s"
onclick={onreset} onclick={onreset}
/> />
<span class="auto-note"><i></i> {t("ui.autoUpdate")}</span>
</div> </div>
<style> <style>
@@ -128,10 +127,4 @@
padding-top: var(--space-l); padding-top: var(--space-l);
border-top: var(--size-border) solid var(--color-border); border-top: var(--size-border) solid var(--color-border);
} }
.auto-note i {
width: var(--size-border-thick);
height: var(--size-border-thick);
border-radius: 50%;
background: var(--color-success);
}
</style> </style>
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import AddStepButton from "./AddStepButton.svelte";
import SchemaFields from "./SchemaFields.svelte"; import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte"; import SchemaPreview from "./SchemaPreview.svelte";
import { debounce } from "$lib/core/debounce"; import StepCard from "$lib/components/display/StepCard.svelte";
import { hasTransparency } from "$lib/core/analyze"; import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce";
import { ToolError, type ErrorVars } from "$lib/core/errors"; import { ToolError, type ErrorVars } from "$lib/core/errors";
import { import {
decodeFile, decodeFile,
@@ -19,18 +21,33 @@
verdictText, verdictText,
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FileResult, Page, Tool } from "$lib/registry"; import {
createChain,
insertStep,
loadChain,
moveStep,
removeStep,
saveChain,
type ChainStep,
} from "$lib/pipeline.svelte";
import {
getTool,
PAGES,
type FileResult,
type Page,
type Tool,
type ToolResult,
} from "$lib/registry";
import { import {
applySourceDefaults, applySourceDefaults,
defaultSchemaParams, defaultSchemaParams,
sanitizeSchemaParams,
withAspectLock, withAspectLock,
type Dimension, type Dimension,
type ToolSchema, type ToolSchema,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import { downloadZip } from "$lib/zip"; import { downloadZip } from "$lib/zip";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity"; import { SvelteMap, SvelteSet } from "svelte/reactivity";
interface Props { interface Props {
page: Page; page: Page;
@@ -46,14 +63,14 @@
); );
const inputMode = $derived(tool.input); const inputMode = $derived(tool.input);
const resultKind = $derived(tool.result ?? "image");
let values = $state<Record<string, unknown>>({}); let steps = $state<ChainStep[]>([]);
// Поля, изменённые пользователем: при загрузке нового файла им не даём // Поля, изменённые пользователем (по шагам): при загрузке нового файла им не
// перезаписаться source-дефолтами. Reset снимает пометку. // даём перезаписаться source-дефолтами. Reset шага снимает пометку.
const touched = new SvelteSet<string>(); const touchedByStep = new SvelteMap<string, SvelteSet<string>>();
// Последняя правленая ось dimension-поля — ведущая при lockAspect. // Последняя правленая ось dimension-поля — ведущая при lockAspect.
const lastAxis: Record<string, "width" | "height"> = {}; const lastAxis: Record<string, "width" | "height"> = {};
let stepDims = $state<(Dimension | undefined)[]>([]);
let source = $state<PixelImage | null>(null); let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null); let result = $state<PixelImage | null>(null);
let fileResult = $state<FileResult | null>(null); let fileResult = $state<FileResult | null>(null);
@@ -64,10 +81,19 @@
); );
let running = $state(false); let running = $state(false);
let displayError = $state<DisplayError | null>(null); let displayError = $state<DisplayError | null>(null);
let started = $state(false); let initedFor = $state("");
let format = $state<OutputMime>("image/png"); let format = $state<OutputMime>("image/png");
// Качество lossy-форматов для инструментов без quality-параметра в схеме. // Качество lossy-форматов для инструментов без quality-параметра в схеме.
let formatQuality = $state<Record<string, number>>({}); let formatQuality = $state<Record<string, number>>({});
let dragFrom = $state<number | null>(null);
const lastTool = $derived(
(steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ??
tool,
);
const resultKind = $derived(lastTool.result ?? "image");
// Цепочку можно продолжить, только если последний шаг отдаёт картинку.
const canExtend = $derived((lastTool.result ?? "image") === "image");
const alphaLoss = $derived( const alphaLoss = $derived(
resultKind === "image" && resultKind === "image" &&
@@ -75,19 +101,20 @@
!outputFormatByMime(format).supportsAlpha && !outputFormatByMime(format).supportsAlpha &&
hasTransparency(result), hasTransparency(result),
); );
// Если в схеме есть quality-параметр (convert-инструменты), он — единый // Если в схеме последнего шага есть quality-параметр (convert-инструменты),
// источник качества; dropdown редактирует его же. // он — единый источник качества; dropdown редактирует его же.
const qualityParamId = $derived(tool.output?.qualityParamId); const qualityParamId = $derived(lastTool.output?.qualityParamId);
const lastParams = $derived(steps.at(-1)?.params);
const currentQuality = $derived.by(() => { const currentQuality = $derived.by(() => {
const setting = outputFormatByMime(format).settings?.quality; const setting = outputFormatByMime(format).settings?.quality;
if (!setting) return undefined; if (!setting) return undefined;
if (qualityParamId) return Number(values[qualityParamId]); if (qualityParamId && lastParams) return Number(lastParams[qualityParamId]);
return formatQuality[format] ?? setting.default; return formatQuality[format] ?? setting.default;
}); });
function setFormatQuality(q: number) { function setFormatQuality(q: number) {
if (qualityParamId) { if (qualityParamId) {
setValue(qualityParamId, q); setStepValue(steps.length - 1, qualityParamId, q);
} else { } else {
formatQuality = { ...formatQuality, [format]: q }; formatQuality = { ...formatQuality, [format]: q };
} }
@@ -103,24 +130,56 @@
const debouncedRun = debounce(() => run(), 200); const debouncedRun = debounce(() => run(), 200);
function init() { function touchedFor(key: string): SvelteSet<string> {
if (!schema || started) return; let set = touchedByStep.get(key);
started = true; if (!set) {
values = defaultSchemaParams(schema); set = new SvelteSet();
format = tool.output?.mime ?? "image/png"; touchedByStep.set(key, set);
}
return set;
} }
function setValue( function toolSchemaOf(step: ChainStep) {
return getTool(step.id)?.schema as ToolSchema<Record<string, unknown>>;
}
function stepTitle(toolId: string): string {
const owner = PAGES.find((p) => p.steps[0].id === toolId);
return owner ? pageTitle(owner) : toolId;
}
function init() {
if (!schema || page.slug === initedFor) return;
initedFor = page.slug;
steps = loadChain(page.slug) ?? createChain(page);
format = lastTool.output?.mime ?? "image/png";
source = null;
result = null;
fileResult = null;
textResult = null;
verdictVars = undefined;
displayError = null;
}
function setStepValue(
index: number,
id: string, id: string,
value: unknown, value: unknown,
axis?: "width" | "height" | "both", axis?: "width" | "height" | "both",
) { ) {
touched.add(id); const step = steps[index];
if (axis) lastAxis[id] = axis === "both" ? "width" : axis; if (!step) return;
const next: Record<string, unknown> = { ...values, [id]: value }; touchedFor(step.key).add(id);
const spec = schema?.fields[id]?.spec; const axisKey = `${step.key}:${id}`;
if (source && schema && spec) { if (axis) lastAxis[axisKey] = axis === "both" ? "width" : axis;
const aspect = source.width / source.height; const next: Record<string, unknown> = { ...step.params, [id]: value };
const stepSchema = toolSchemaOf(step);
const spec = stepSchema?.fields[id]?.spec;
// Аспект считается от входа шага: для первого — исходник, для остальных —
// результат предыдущего шага (размеры известны после прогона).
const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1];
if (dims && stepSchema && spec) {
const aspect = dims.width / dims.height;
if ( if (
spec.kind === "dimension" && spec.kind === "dimension" &&
spec.lockAspectWith && spec.lockAspectWith &&
@@ -129,31 +188,36 @@
) { ) {
next[id] = withAspectLock( next[id] = withAspectLock(
value as Dimension, value as Dimension,
lastAxis[id] ?? "width", lastAxis[axisKey] ?? "width",
aspect, aspect,
); );
} else if (spec.kind === "checkbox" && value === true) { } else if (spec.kind === "checkbox" && value === true) {
// Включили lockAspect — сразу подгоняем привязанные поля под аспект. // Включили lockAspect — сразу подгоняем привязанные поля под аспект.
for (const [fid, f] of Object.entries(schema.fields)) { for (const [fid, f] of Object.entries(stepSchema.fields)) {
const fs = f.spec; const fs = f.spec;
if (fs.kind === "dimension" && fs.lockAspectWith === id) { if (fs.kind === "dimension" && fs.lockAspectWith === id) {
next[fid] = withAspectLock( next[fid] = withAspectLock(
next[fid] as Dimension, next[fid] as Dimension,
lastAxis[fid] ?? "width", lastAxis[`${step.key}:${fid}`] ?? "width",
aspect, aspect,
); );
} }
} }
} }
} }
values = next; steps = steps.with(index, { ...step, params: next });
} }
function reset() { function resetStep(index: number) {
touched.clear(); const step = steps[index];
values = schema if (!step) return;
? defaultSchemaParams(schema, { source: source ?? undefined }) touchedFor(step.key).clear();
const stepSchema = toolSchemaOf(step);
const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1];
const params = stepSchema
? defaultSchemaParams(stepSchema, { source: dims })
: {}; : {};
steps = steps.with(index, { ...step, params });
result = null; result = null;
fileResult = null; fileResult = null;
textResult = null; textResult = null;
@@ -161,36 +225,44 @@
displayError = null; displayError = null;
} }
function addStepAt(index: number, toolId: string) {
steps = insertStep(steps, index, toolId);
}
function removeStepAt(key: string) {
steps = removeStep(steps, key);
}
function onStepDrop(e: DragEvent, to: number) {
e.preventDefault();
if (dragFrom !== null) steps = moveStep(steps, dragFrom, to);
dragFrom = null;
}
async function handleFile(file: File) { async function handleFile(file: File) {
displayError = null; displayError = null;
try { try {
const decoded = await decodeFile(file); const decoded = await decodeFile(file);
source = decoded; source = decoded;
if (schema) { const first = steps[0];
values = applySourceDefaults( const firstSchema = first ? toolSchemaOf(first) : undefined;
schema, if (first && firstSchema) {
values, steps = steps.with(0, {
...first,
params: applySourceDefaults(
firstSchema,
first.params,
{ source: decoded }, { source: decoded },
touched, touchedFor(first.key),
); ),
});
} }
} catch (e) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
} }
} }
async function run() { function assignResult(out: ToolResult) {
if (!schema) return;
displayError = null;
running = true;
try {
const out = await execute(tool, {
params: sanitizeSchemaParams(schema, values, {
source: source ?? undefined,
}),
source: source ?? undefined,
text: textSource || undefined,
});
if (resultKind === "image") { if (resultKind === "image") {
result = out as PixelImage; result = out as PixelImage;
textResult = null; textResult = null;
@@ -213,6 +285,47 @@
result = null; result = null;
fileResult = null; fileResult = null;
} }
}
async function run() {
if (!schema || steps.length === 0) return;
displayError = null;
running = true;
try {
let current: PixelImage | undefined = source ?? undefined;
const dims: (Dimension | undefined)[] = [];
let out: ToolResult = "";
for (let i = 0; i < steps.length; i++) {
const stepTool = getTool(steps[i].id);
if (!stepTool) continue;
try {
out = await execute(stepTool, {
params: steps[i].params,
source: stepTool.input === "image" ? current : undefined,
text:
stepTool.input === "text" ? textSource || undefined : undefined,
});
} catch (e) {
const base = toDisplayError(e);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
throw new Error(
t("chain.stepError", {
n: i + 1,
title: stepTitle(stepTool.id),
msg,
}),
{ cause: e },
);
}
if (out && typeof out === "object" && "data" in out) {
current = out as PixelImage;
dims[i] = { width: current.width, height: current.height };
} else {
current = undefined;
}
}
stepDims = dims;
assignResult(out);
} catch (e) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
} finally { } finally {
@@ -247,7 +360,7 @@
if (!textResult) return; if (!textResult) return;
const copyValue = const copyValue =
resultKind === "verdict" resultKind === "verdict"
? verdictText(tool.id, textResult, verdictVars) ? verdictText(lastTool.id, textResult, verdictVars)
: textResult; : textResult;
try { try {
await navigator.clipboard.writeText(copyValue); await navigator.clipboard.writeText(copyValue);
@@ -270,6 +383,13 @@
init(); init();
}); });
// Цепочка персистится по slug страницы; сохраняем после инициализации.
$effect(() => {
if (!initedFor) return;
void steps;
saveChain(initedFor, steps);
});
onMount(() => { onMount(() => {
if (inputMode !== "image") return; if (inputMode !== "image") return;
function onPaste(e: ClipboardEvent) { function onPaste(e: ClipboardEvent) {
@@ -290,10 +410,10 @@
}); });
$effect(() => { $effect(() => {
if (!started) return; if (!initedFor) return;
if (inputMode === "image" && !source) return; if (inputMode === "image" && !source) return;
if (inputMode === "text" && !textSource.trim()) return; if (inputMode === "text" && !textSource.trim()) return;
void values; void steps;
void source; void source;
debouncedRun(); debouncedRun();
return () => debouncedRun.cancel(); return () => debouncedRun.cancel();
@@ -318,23 +438,49 @@
</header> </header>
<div class="workspace"> <div class="workspace">
<section class="panel settings"> <section class="settings">
<div class="panel-head"> {#if canExtend}
<span class="label">{t("paramsCard.toolSettings")}</span> <AddStepButton onadd={(id) => addStepAt(0, id)} />
<strong>{t("paramsCard.configureOutput")}</strong> {/if}
</div> {#each steps as step, i (step.key)}
<StepCard
index={i + 1}
title={stepTitle(step.id)}
draggable
onremove={steps.length > 1
? () => removeStepAt(step.key)
: undefined}
ondragstart={(e) => {
dragFrom = i;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(i));
}
}}
ondragover={(e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
}}
ondrop={(e) => onStepDrop(e, i)}
ondragend={() => (dragFrom = null)}
>
<SchemaFields <SchemaFields
{schema} schema={toolSchemaOf(step)}
{values} values={step.params}
toolId={tool.id} toolId={step.id}
onchange={setValue} onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
onreset={reset} onreset={() => resetStep(i)}
/> />
</StepCard>
{#if canExtend}
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
{/if}
{/each}
</section> </section>
<section class="panel"> <section class="panel">
<SchemaPreview <SchemaPreview
toolId={tool.id} toolId={lastTool.id}
{source} {source}
{result} {result}
{fileResult} {fileResult}
@@ -384,15 +530,6 @@
line-height: 1.1; line-height: 1.1;
color: var(--color-text); color: var(--color-text);
} }
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-text-muted);
}
.label {
color: var(--color-main);
}
.lede { .lede {
max-width: 100%; max-width: 100%;
margin: var(--space-m) 0 0; margin: var(--space-m) 0 0;
@@ -415,19 +552,6 @@
background: var(--color-panel); background: var(--color-panel);
padding: var(--space-xl); padding: var(--space-xl);
} }
.panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.panel-head strong {
font-size: var(--font-size-l);
color: var(--color-text);
}
.no-schema { .no-schema {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
+1 -4
View File
@@ -48,7 +48,7 @@ export const en: Dict = {
stepError: "Step {n} ({title}): {msg}", stepError: "Step {n} ({title}): {msg}",
}, },
chain: { chain: {
stepLabel: "Step {n}: {title}", addStep: "Add step",
inputLegend: "Input", inputLegend: "Input",
resultLegend: "Result", resultLegend: "Result",
paramsLegend: "Parameters", paramsLegend: "Parameters",
@@ -78,9 +78,7 @@ export const en: Dict = {
alt: "Result image", alt: "Result image",
}, },
paramsCard: { paramsCard: {
toolSettings: "Tool settings",
noParams: "This tool has no parameters — the result is ready as is.", noParams: "This tool has no parameters — the result is ready as is.",
configureOutput: "Configure output",
noSchema: "This tool has no schema yet.", noSchema: "This tool has no schema yet.",
}, },
textInput: { textInput: {
@@ -142,7 +140,6 @@ export const en: Dict = {
overlayTitle: "Watermark", overlayTitle: "Watermark",
overlayDrop: "Drop a watermark PNG or click", overlayDrop: "Drop a watermark PNG or click",
overlayRemove: "Remove watermark", overlayRemove: "Remove watermark",
autoUpdate: "updates automatically",
width: "Width", width: "Width",
height: "Height", height: "Height",
from: "From", from: "From",
+1 -4
View File
@@ -49,7 +49,7 @@ export const ru: Dict = {
stepError: "Шаг {n} ({title}): {msg}", stepError: "Шаг {n} ({title}): {msg}",
}, },
chain: { chain: {
stepLabel: "Шаг {n}: {title}", addStep: "Добавить шаг",
inputLegend: "Вход", inputLegend: "Вход",
resultLegend: "Результат", resultLegend: "Результат",
paramsLegend: "Параметры", paramsLegend: "Параметры",
@@ -79,9 +79,7 @@ export const ru: Dict = {
alt: "Изображение-результат", alt: "Изображение-результат",
}, },
paramsCard: { paramsCard: {
toolSettings: "Настройки инструмента",
noParams: "У этого инструмента нет параметров — результат уже готов.", noParams: "У этого инструмента нет параметров — результат уже готов.",
configureOutput: "Настроить вывод",
noSchema: "У этого инструмента пока нет схемы.", noSchema: "У этого инструмента пока нет схемы.",
}, },
textInput: { textInput: {
@@ -143,7 +141,6 @@ export const ru: Dict = {
overlayTitle: "Знак", overlayTitle: "Знак",
overlayDrop: "Перетащите PNG-знак или нажмите", overlayDrop: "Перетащите PNG-знак или нажмите",
overlayRemove: "Убрать знак", overlayRemove: "Убрать знак",
autoUpdate: "обновляется автоматически",
width: "Ширина", width: "Ширина",
height: "Высота", height: "Высота",
from: "От", from: "От",
-1
View File
@@ -39,7 +39,6 @@ describe("Смоук §6 i18n (новый UI)", () => {
], ],
["chain.inputLegend", "Вход", "Input"], ["chain.inputLegend", "Вход", "Input"],
["resultCard.nextTool", "Следующий инструмент", "Next tool"], ["resultCard.nextTool", "Следующий инструмент", "Next tool"],
["paramsCard.toolSettings", "Настройки инструмента", "Tool settings"],
["download.busy", "Готовим файл", "Preparing file"], ["download.busy", "Готовим файл", "Preparing file"],
["dropZone.pickDefault", "Перетащите изображение", "Drop an image"], ["dropZone.pickDefault", "Перетащите изображение", "Drop an image"],
]; ];
+142
View File
@@ -0,0 +1,142 @@
import { getTool, PAGES, type Page } from "./registry";
import { defaultSchemaParams, sanitizeSchemaParams } from "./registry-schema";
export interface ChainStep {
/** Стабильный ключ для keyed each и drag-n-drop (переживает reorder). */
key: string;
id: string;
params: Record<string, unknown>;
}
export const CHAIN_VERSION = 1;
function uid(): string {
return crypto.randomUUID();
}
function storageKey(slug: string): string {
return `pipeline:${slug}`;
}
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
/** Инструмент можно вставить в середину цепочки: принимает и отдаёт картинку. */
export function isChainable(toolId: string): boolean {
const tool = getTool(toolId);
return (
tool !== undefined &&
tool.input === "image" &&
(tool.result ?? "image") === "image"
);
}
/** Страницы, чьи инструменты можно добавлять шагами (для picker'а). */
export function chainablePages(): Page[] {
return PAGES.filter((p) => isChainable(p.steps[0].id));
}
export function createStep(id: string): ChainStep | null {
const tool = getTool(id);
if (!tool) return null;
return { key: uid(), id, params: defaultSchemaParams(tool.schema) };
}
export function createChain(page: Page): ChainStep[] {
const steps: ChainStep[] = [];
for (const s of page.steps) {
const tool = getTool(s.id);
if (!tool) continue;
steps.push({
key: uid(),
id: s.id,
params: sanitizeSchemaParams(tool.schema, s.params ?? {}),
});
}
return steps;
}
/** Валидация сохранённой цепочки; невалидные данные → null (дефолт страницы). */
export function parseChain(data: unknown): ChainStep[] | null {
if (
!data ||
typeof data !== "object" ||
(data as { version?: unknown }).version !== CHAIN_VERSION ||
!Array.isArray((data as { steps?: unknown }).steps)
) {
return null;
}
const out: ChainStep[] = [];
for (const raw of (data as { steps: unknown[] }).steps) {
if (!raw || typeof raw !== "object") return null;
const { id, params } = raw as { id?: unknown; params?: unknown };
if (typeof id !== "string") return null;
const tool = getTool(id);
if (!tool) return null;
out.push({
key: uid(),
id,
params: sanitizeSchemaParams(
tool.schema,
(params ?? {}) as Record<string, unknown>,
),
});
}
return out.length > 0 ? out : null;
}
export function loadChain(slug: string): ChainStep[] | null {
const raw = storage()?.getItem(storageKey(slug));
if (!raw) return null;
try {
return parseChain(JSON.parse(raw));
} catch {
return null;
}
}
export function saveChain(slug: string, steps: ChainStep[]): void {
storage()?.setItem(
storageKey(slug),
JSON.stringify({
version: CHAIN_VERSION,
steps: steps.map((s) => ({ id: s.id, params: s.params })),
}),
);
}
export function insertStep(
steps: ChainStep[],
index: number,
id: string,
): ChainStep[] {
const step = createStep(id);
if (!step) return steps;
return [...steps.slice(0, index), step, ...steps.slice(index)];
}
export function removeStep(steps: ChainStep[], key: string): ChainStep[] {
if (steps.length <= 1) return steps;
return steps.filter((s) => s.key !== key);
}
export function moveStep(
steps: ChainStep[],
from: number,
to: number,
): ChainStep[] {
if (
from === to ||
from < 0 ||
to < 0 ||
from >= steps.length ||
to >= steps.length
) {
return steps;
}
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
CHAIN_VERSION,
chainablePages,
createChain,
insertStep,
isChainable,
moveStep,
parseChain,
removeStep,
} from "./pipeline.svelte";
import { getPageBySlug } from "./registry";
const cropPage = getPageBySlug("crop-png")!;
describe("pipeline: цепочка шагов", () => {
it("createChain собирает шаги из страницы с дефолтами схемы", () => {
const chain = createChain(cropPage);
expect(chain).toHaveLength(1);
expect(chain[0].id).toBe("crop");
expect(chain[0].params).toMatchObject({ x: 0, y: 0 });
});
it("isChainable: image→image можно, терминальные и text-вход нельзя", () => {
expect(isChainable("resize")).toBe(true);
expect(isChainable("to-base64")).toBe(false); // result: text
expect(isChainable("split-into-parts")).toBe(false); // result: files
expect(isChainable("from-svg")).toBe(false); // input: text
expect(isChainable("unknown-tool")).toBe(false);
});
it("chainablePages содержит только совместимые страницы", () => {
const pages = chainablePages();
expect(pages.some((p) => p.slug === "resize-png")).toBe(true);
expect(pages.some((p) => p.slug === "png-to-base64")).toBe(false);
});
it("insertStep вставляет шаг в произвольную позицию", () => {
const chain = createChain(cropPage);
const grown = insertStep(chain, 0, "resize");
expect(grown.map((s) => s.id)).toEqual(["resize", "crop"]);
expect(grown[0].params).toHaveProperty("keepAspect");
// исходная цепочка не мутирует
expect(chain).toHaveLength(1);
});
it("removeStep не удаляет последний шаг", () => {
const chain = createChain(cropPage);
expect(removeStep(chain, chain[0].key)).toHaveLength(1);
const grown = insertStep(chain, 1, "flip");
const shrunk = removeStep(grown, grown[0].key);
expect(shrunk.map((s) => s.id)).toEqual(["flip"]);
});
it("moveStep меняет порядок, ключи шагов сохраняются", () => {
let chain = createChain(cropPage);
chain = insertStep(chain, 1, "flip");
chain = insertStep(chain, 2, "grayscale");
const keys = chain.map((s) => s.key);
const moved = moveStep(chain, 2, 0);
expect(moved.map((s) => s.id)).toEqual(["grayscale", "crop", "flip"]);
expect(moved.map((s) => s.key)).toEqual([keys[2], keys[0], keys[1]]);
expect(moveStep(chain, 0, 5)).toBe(chain);
});
});
describe("pipeline: parseChain (localStorage/импорт)", () => {
const valid = {
version: CHAIN_VERSION,
steps: [
{ id: "crop", params: { x: 5 } },
{ id: "resize", params: {} },
],
};
it("валидная цепочка разбирается, параметры санитизируются", () => {
const chain = parseChain(valid)!;
expect(chain.map((s) => s.id)).toEqual(["crop", "resize"]);
expect(chain[0].params).toMatchObject({ x: 5, y: 0 });
});
it.each([
["нет версии", { steps: [{ id: "crop" }] }],
["другая версия", { version: 99, steps: [{ id: "crop" }] }],
["пустой список", { version: CHAIN_VERSION, steps: [] }],
[
"неизвестный инструмент",
{ version: CHAIN_VERSION, steps: [{ id: "nope" }] },
],
["мусор", "just a string"],
])("невалидные данные → null (%s)", (_name, data) => {
expect(parseChain(data)).toBeNull();
});
});