Compare commits

...
6 Commits
23 changed files with 1262 additions and 1006 deletions
+13
View File
@@ -2,6 +2,19 @@
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - - - - -
## [v0.7.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b..v0.7.0) - 2026-10-10
#### Features
- update dnd tools in pipeline - ([7bbcec5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b)) - Ku6epXBOCTuK
#### Bug Fixes
- update drag-n-drop tools - overlay, page scrim - ([dd9cb6d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dd9cb6d574181ffa7520adeff5faef5cbbd494ac)) - Ku6epXBOCTuK
- update dnd tools in pipeline - ([e866975](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e866975045aebda6353808d419189f6aae986687)) - Ku6epXBOCTuK
#### Performance Improvements
- add cache for images - ([5f4c383](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5f4c3833a199044c875b5b8e3dd8aa52b472f8a5)) - Ku6epXBOCTuK
#### Refactoring
- separate logic from schema tool view component - ([b7613e8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b7613e8c0ff82469de2585b1572fbae8f5a796fa)) - Ku6epXBOCTuK
- - -
## [v0.6.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/bac82f8442a8262e757d18f0cc2b5e7fb8291516..v0.6.0) - 2026-10-10 ## [v0.6.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/bac82f8442a8262e757d18f0cc2b5e7fb8291516..v0.6.0) - 2026-10-10
#### Features #### Features
- flow for multiple files - pipeline, tools - ([bac82f8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bac82f8442a8262e757d18f0cc2b5e7fb8291516)) - Ku6epXBOCTuK - flow for multiple files - pipeline, tools - ([bac82f8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bac82f8442a8262e757d18f0cc2b5e7fb8291516)) - Ku6epXBOCTuK
+8
View File
@@ -168,6 +168,14 @@
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?) - [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] обновить страницу kit или сделать storybook - [ ] обновить страницу kit или сделать storybook
- [ ] **проверить `cachedDataUrl` (WeakMap-кэш превью) в реальной работе** — GC
срабатывает недетерминированно, поэтому освобождение base64-строк (≈1.3×
бинарного размера картинки на каждый тайл) не гарантировано своевременно.
Проверить на длинной сессии с большими картинками (несколько ранов подряд,
смена инструментов): растёт ли heap в DevTools → Memory после повторных
ранов и уходит ли после принудительной сборки. Если записи копятся —
заменить на ограниченный LRU или явную инвалидацию при
`resetForHost`/новом ране (`web/src/lib/core/io.ts`, `cachedDataUrl`)
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`, - [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
поэтому она не следует за пропорциями результата — широкий результат поэтому она не следует за пропорциями результата — широкий результат
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно, уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "easy-png-tools", "name": "easy-png-tools",
"version": "0.6.0", "version": "0.7.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.6.0", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -52,6 +52,8 @@
"vitest": "^4.1.11" "vitest": "^4.1.11"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/dom": "^0.3.2",
"@dnd-kit/svelte": "^0.3.2",
"@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0",
"@lucide/svelte": "^1.33.0", "@lucide/svelte": "^1.33.0",
+74
View File
@@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
'@dnd-kit/dom':
specifier: ^0.3.2
version: 0.3.2
'@dnd-kit/svelte':
specifier: ^0.3.2
version: 0.3.2(svelte@5.56.9(@typescript-eslint/types@8.67.0))
'@fontsource/ibm-plex-mono': '@fontsource/ibm-plex-mono':
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
@@ -196,6 +202,26 @@ packages:
peerDependencies: peerDependencies:
postcss-selector-parser: ^7.1.1 postcss-selector-parser: ^7.1.1
'@dnd-kit/abstract@0.3.2':
resolution: {integrity: sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q==}
'@dnd-kit/collision@0.3.2':
resolution: {integrity: sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA==}
'@dnd-kit/dom@0.3.2':
resolution: {integrity: sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg==}
'@dnd-kit/geometry@0.3.2':
resolution: {integrity: sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w==}
'@dnd-kit/state@0.3.2':
resolution: {integrity: sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A==}
'@dnd-kit/svelte@0.3.2':
resolution: {integrity: sha512-HkTnx/3GnxfVNqHSojxhxp5r+KymL9Q7UI2gonXN2+bmdObw03FSQOjicOoRzlV2u9o5DtK1sU4YUQAfkLCGGQ==}
peerDependencies:
svelte: ^5.29.0
'@eslint-community/eslint-utils@4.10.1': '@eslint-community/eslint-utils@4.10.1':
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -313,6 +339,9 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@preact/signals-core@1.14.4':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
'@rolldown/binding-android-arm64@1.2.4': '@rolldown/binding-android-arm64@1.2.4':
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1518,6 +1547,9 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1744,6 +1776,44 @@ snapshots:
dependencies: dependencies:
postcss-selector-parser: 7.1.5 postcss-selector-parser: 7.1.5
'@dnd-kit/abstract@0.3.2':
dependencies:
'@dnd-kit/geometry': 0.3.2
'@dnd-kit/state': 0.3.2
tslib: 2.8.1
'@dnd-kit/collision@0.3.2':
dependencies:
'@dnd-kit/abstract': 0.3.2
'@dnd-kit/geometry': 0.3.2
tslib: 2.8.1
'@dnd-kit/dom@0.3.2':
dependencies:
'@dnd-kit/abstract': 0.3.2
'@dnd-kit/collision': 0.3.2
'@dnd-kit/geometry': 0.3.2
'@dnd-kit/state': 0.3.2
tslib: 2.8.1
'@dnd-kit/geometry@0.3.2':
dependencies:
'@dnd-kit/state': 0.3.2
tslib: 2.8.1
'@dnd-kit/state@0.3.2':
dependencies:
'@preact/signals-core': 1.14.4
tslib: 2.8.1
'@dnd-kit/svelte@0.3.2(svelte@5.56.9(@typescript-eslint/types@8.67.0))':
dependencies:
'@dnd-kit/abstract': 0.3.2
'@dnd-kit/dom': 0.3.2
'@dnd-kit/state': 0.3.2
svelte: 5.56.9(@typescript-eslint/types@8.67.0)
tslib: 2.8.1
'@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(supports-color@10.2.2))': '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(supports-color@10.2.2))':
dependencies: dependencies:
eslint: 10.9.0(supports-color@10.2.2) eslint: 10.9.0(supports-color@10.2.2)
@@ -1847,6 +1917,8 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@preact/signals-core@1.14.4': {}
'@rolldown/binding-android-arm64@1.2.4': '@rolldown/binding-android-arm64@1.2.4':
optional: true optional: true
@@ -2991,6 +3063,8 @@ snapshots:
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
tslib@2.8.1: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
+1
View File
@@ -177,6 +177,7 @@
--duration-l: 0.8s; --duration-l: 0.8s;
--z-dropdown: 10; --z-dropdown: 10;
--z-drag: 20;
} }
[data-theme="dark"] { [data-theme="dark"] {
+19 -25
View File
@@ -3,55 +3,44 @@
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";
import type { Attachment } from "svelte/attachments";
interface Props { interface Props {
index: number; index: number;
title?: string; title?: string;
type?: string; type?: string;
draggable?: boolean;
collapsed?: boolean; collapsed?: boolean;
tools?: Snippet; tools?: Snippet;
children?: Snippet; children?: Snippet;
onremove?: () => void; onremove?: () => void;
ontoggle?: () => void; ontoggle?: () => void;
ondragstart?: (e: DragEvent) => void; // dnd-kit attachments wired by SortableStepCard; noop on static usage.
ondragover?: (e: DragEvent) => void; elementAttachment?: Attachment<HTMLElement>;
ondrop?: (e: DragEvent) => void; handleAttachment?: Attachment<HTMLElement>;
ondragend?: () => void;
} }
const noopAttach: Attachment<HTMLElement> = () => {};
let { let {
index, index,
title, title,
type, type,
draggable = false,
collapsed = false, collapsed = false,
tools, tools,
children, children,
onremove, onremove,
ontoggle, ontoggle,
ondragstart, elementAttachment = noopAttach,
ondragover, handleAttachment = noopAttach,
ondrop,
ondragend,
}: Props = $props(); }: Props = $props();
</script> </script>
<article <article class="step-card" {@attach elementAttachment}>
class="step-card" <header class="step-head" role="button" tabindex="0">
ondragover={(e) => ondragover?.(e)}
ondrop={(e) => ondrop?.(e)}
>
<header
class="step-head"
role="button"
tabindex="0"
{draggable}
ondragstart={(e) => ondragstart?.(e)}
ondragend={() => ondragend?.()}
>
<span class="step-index">{index.toString().padStart(2, "0")}</span> <span class="step-index">{index.toString().padStart(2, "0")}</span>
<GripVertical size={16} class="drag" aria-hidden="true" /> <span class="drag-handle" {@attach handleAttachment}>
<GripVertical size={16} class="drag" aria-hidden="true" />
</span>
<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}
@@ -101,6 +90,12 @@
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
color: var(--color-main); color: var(--color-main);
} }
.drag-handle {
display: flex;
align-items: center;
cursor: grab;
touch-action: none;
}
.step-heading-text { .step-heading-text {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -127,6 +122,5 @@
} }
.step-card :global(.drag) { .step-card :global(.drag) {
color: var(--color-border); color: var(--color-border);
cursor: grab;
} }
</style> </style>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { toDataUrl } from "$lib/core/io"; import { cachedDataUrl } from "$lib/core/io";
import type { ToolImageFile } from "$lib/registry"; import type { ToolImageFile } from "$lib/registry";
interface Props { interface Props {
@@ -8,7 +8,7 @@
let { files }: Props = $props(); let { files }: Props = $props();
const parts = $derived( const parts = $derived(
files.map((file) => ({ name: file.name, url: toDataUrl(file.image) })), files.map((file) => ({ name: file.name, url: cachedDataUrl(file.image) })),
); );
</script> </script>
@@ -1,355 +0,0 @@
<script lang="ts">
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { toDataUrl, type OutputMime } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { ChainStep } from "$lib/pipeline.svelte";
import type {
FileResult,
InputMode,
ResultKind,
ResultNote,
ToolImageFile,
} from "$lib/registry";
import type { Snippet } from "svelte";
import AddStepButton from "./AddStepButton.svelte";
import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte";
import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
interface Props {
steps: ChainStep[];
stepCard: Snippet<[ChainStep, number]>;
canExtend: boolean;
aligned: boolean;
inputMode: InputMode;
source: PixelImage | null;
sourceFiles: ToolImageFile[];
textSource: string;
running: boolean;
resultKind: ResultKind;
result: PixelImage | null;
resultNote: ResultNote | null;
fileResult: FileResult | null;
textResult: string | null;
textVars: Record<string, string | number> | undefined;
toolId: string;
format: OutputMime;
quality: number | undefined;
limitKb: number | undefined;
alphaLoss: boolean;
errorText: string;
warnings: string[];
stepResults: (PixelImage | null)[];
stepFileSets: ToolImageFile[][];
stepMasks: (PixelImage | null)[];
stepMaskOn: boolean[];
stepMaskable: boolean[];
onaddstep: (index: number, toolId: string) => void;
ontogglestepmask: (index: number) => void;
ontextinput: (text: string) => void;
onrendertext: () => void;
onupload: (file: File) => void;
onuploadmany: (files: File[]) => void;
ondownload: () => void;
oncopy: () => void;
ondownloadtxt: () => void;
onformat: (mime: OutputMime) => void;
onquality: (value: number) => void;
onlimit: (kb: number | undefined) => void;
}
let {
steps,
stepCard,
canExtend,
aligned = $bindable(false),
inputMode,
source,
sourceFiles,
textSource,
running,
resultKind,
result,
resultNote,
fileResult,
textResult,
textVars,
toolId,
format,
quality,
limitKb,
alphaLoss,
errorText,
warnings,
stepResults,
stepFileSets,
stepMasks,
stepMaskOn,
stepMaskable,
onaddstep,
ontogglestepmask,
ontextinput,
onrendertext,
onupload,
onuploadmany,
ondownload,
oncopy,
ondownloadtxt,
onformat,
onquality,
onlimit,
}: Props = $props();
</script>
{#snippet maskChip(i: number)}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i]}
onclick={() => ontogglestepmask(i)}
/>
{/if}
{/snippet}
<div class="aligned">
<div class="aligned-row">
<div></div>
<div class="panel-segment head-segment">
<span class="label">{t("resultCard.previewPanel")}</span>
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
<SchemaActions
{inputMode}
{resultKind}
canDownload={result !== null}
{running}
{format}
{quality}
{limitKb}
{alphaLoss}
{onupload}
{ondownload}
{onformat}
{onquality}
{onlimit}
/>
</div>
</div>
{#if errorText}
<p class="error" role="alert">{errorText}</p>
{/if}
{#each warnings as w (w)}
<p class="warn" role="status">{w}</p>
{/each}
{#if canExtend}
<div class="aligned-row">
<AddStepButton onadd={(id) => onaddstep(0, id)} />
<div class="panel-segment connect-segment"></div>
</div>
{/if}
{#each steps as step, i (step.key)}
{@const input = i === 0 ? null : stepResults[i - 1]}
{@const out = stepResults[i]}
{#if i > 0}
<div class="group-divider">
<div class="gd-cell"></div>
</div>
{/if}
<div class="aligned-row">
{@render stepCard(step, i)}
<div
class="panel-segment pair-segment"
class:last-segment={i === steps.length - 1 && !canExtend}
>
{#if i === 0}
<SchemaSourceTile
mode={inputMode}
{source}
sources={sourceFiles}
{textSource}
{running}
fileCount={sourceFiles.length}
{ontextinput}
{onrendertext}
{onupload}
{onuploadmany}
/>
{:else}
{@const inSet = stepFileSets[i - 1] ?? []}
<PreviewTile
label={t("chain.inputLegend")}
viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined}
parts={inSet.length > 1 ? inSet.length : undefined}
>
{#if inSet.length > 1}
<PartsGrid files={inSet} />
{:else if input}
<img src={toDataUrl(input)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
{#if i === steps.length - 1}
<SchemaResultTile
{resultKind}
result={out}
{resultNote}
{fileResult}
{textResult}
{textVars}
{toolId}
{running}
mask={stepMasks[i] ?? null}
maskOn={stepMaskOn[i] ?? false}
ontogglemask={stepMaskable[i]
? () => ontogglestepmask(i)
: undefined}
{oncopy}
{ondownloadtxt}
/>
{:else}
{@const outSet = stepFileSets[i] ?? []}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
parts={outSet.length > 1 ? outSet.length : undefined}
>
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if outSet.length > 1 && !stepMaskOn[i]}
<PartsGrid files={outSet} />
{:else if out}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
)}
alt=""
/>
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
</div>
</div>
{#if canExtend}
<div class="aligned-row">
<AddStepButton onadd={(id) => onaddstep(i + 1, id)} />
<div
class="panel-segment connect-segment"
class:add-last={i === steps.length - 1}
></div>
</div>
{/if}
{/each}
</div>
<style>
.aligned {
display: grid;
}
.aligned-row {
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
align-items: start;
}
/* Сегменты справа образуют «одну панель»: общий фон, боковые границы,
верх у шапки и низ у последней строки. */
.panel-segment {
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-xl);
}
.head-segment {
display: flex;
align-items: center;
gap: var(--space-xl);
flex-wrap: wrap;
border-top: var(--size-border) solid var(--color-border);
border-bottom: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m) var(--radius-m) 0 0;
}
.head-segment .label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.pair-segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
/* Коннектор в строках «Add step»: продолжает боковые границы панели. */
.connect-segment {
align-self: stretch;
padding: 0;
}
.connect-segment.add-last {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
/* Полоса-разделитель между группами: ячейки с фоном и боковыми границами
(как у карточки слева и панели справа), сверху — линия через всю ширину. */
.group-divider {
position: relative;
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
}
.gd-cell {
grid-column: 2;
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-s) 0;
}
.group-divider::after {
content: "";
position: absolute;
inset: 0 0 auto;
border-top: var(--size-border) solid var(--color-border);
}
.last-segment {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error {
margin: 0;
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.warn {
margin: 0;
color: var(--color-warning);
font: var(--font-size-s) var(--font-mono);
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
@media (--bp-tablet) {
.aligned-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,178 +0,0 @@
<script lang="ts">
import {
hasPreviewResult,
type PreviewHeadModel,
type PreviewResultModel,
type PreviewSourceModel,
type PreviewStepsModel,
} from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { InputMode, ResultKind } from "$lib/registry";
interface Props {
inputMode: InputMode;
resultKind?: ResultKind;
toolId: string;
running?: boolean;
error?: string;
aligned?: boolean;
head: PreviewHeadModel;
source: PreviewSourceModel;
steps?: PreviewStepsModel;
result: PreviewResultModel;
}
let {
inputMode,
resultKind = "image",
toolId,
running = false,
error = "",
aligned = $bindable(false),
head,
source,
steps = {},
result,
}: Props = $props();
const stepResults = $derived(steps.results ?? []);
const stepFileSets = $derived(steps.fileSets ?? []);
const stepMaskable = $derived(steps.maskable ?? []);
const stepMaskOn = $derived(steps.maskOn ?? []);
const stepMasks = $derived(steps.masks ?? []);
const hasResult = $derived(
hasPreviewResult({
inputMode,
resultKind,
result: result.result,
fileResult: result.fileResult ?? null,
textResult: result.textResult ?? null,
}),
);
</script>
<div class="panel-head">
<span class="label">{t("resultCard.previewPanel")}</span>
{#if stepResults.length > 1}
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
{/if}
<SchemaActions
{inputMode}
{resultKind}
canDownload={hasResult}
{running}
{...head}
/>
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="pair">
<SchemaSourceTile
mode={inputMode}
{running}
onupload={head.onupload}
{...source}
/>
{#each stepResults.slice(0, -1) as img, i (i)}
{@const set = stepFileSets[i] ?? []}
{#if set.length > 1 && !stepMaskOn[i]}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
parts={set.length}
partsDims={`${set[0].image.width} × ${set[0].image.height}`}
>
{#snippet actions()}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => steps.ontogglestepmask?.(i)}
/>
{/if}
{/snippet}
<PartsGrid files={set} />
</PreviewTile>
{:else if img}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={`${img.width} × ${img.height}`}
>
{#snippet actions()}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => steps.ontogglestepmask?.(i)}
/>
{/if}
{/snippet}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? (stepMasks[i] as PixelImage) : img,
)}
alt=""
/>
</PreviewTile>
{/if}
{/each}
<SchemaResultTile {resultKind} {toolId} {running} {...result} />
</div>
<style>
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error {
margin: 0 0 var(--space-l);
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
@media (--bp-tablet) {
.pair {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { toDataUrl } from "$lib/core/io"; import { cachedDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FileResult, ResultKind, ResultNote } from "$lib/registry"; import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
@@ -41,7 +41,7 @@
}: Props = $props(); }: Props = $props();
const shown = $derived(maskOn && mask ? mask : result); const shown = $derived(maskOn && mask ? mask : result);
const resultUrl = $derived(shown ? toDataUrl(shown) : null); const resultUrl = $derived(shown ? cachedDataUrl(shown) : null);
const resultDims = $derived( const resultDims = $derived(
resultKind === "image" && result resultKind === "image" && result
? `${result.width} × ${result.height}` ? `${result.width} × ${result.height}`
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { toDataUrl } from "$lib/core/io"; import { cachedDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { InputMode, ToolImageFile } from "$lib/registry"; import type { InputMode, ToolImageFile } from "$lib/registry";
@@ -33,7 +33,7 @@
onuploadmany, onuploadmany,
}: Props = $props(); }: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null); const sourceUrl = $derived(source ? cachedDataUrl(source) : null);
const sourceDims = $derived( const sourceDims = $derived(
source source
? `${source.width} × ${source.height}` + ? `${source.width} × ${source.height}` +
@@ -1,60 +1,44 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import StepCard from "$lib/components/display/StepCard.svelte";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define"; import { ButtonVariantDefine } from "$lib/components/ui/define";
import { isFav, toggleFav } from "$lib/favorites.svelte"; import { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte"; import { Star } from "@lucide/svelte";
import { hasTransparency } from "$lib/core/analyze"; import { DragDropProvider } from "@dnd-kit/svelte";
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { ToolError } from "$lib/core/errors"; import { cachedDataUrl } from "$lib/core/io";
import { decodeFile, type OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/output-formats";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { baseName, uniqueName, type ChainWarning } from "$lib/run-chain";
import { import {
chainStepTitle,
chainWarningText,
pageDescription, pageDescription,
pageTitle, pageTitle,
chainStepTitle,
} 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 { import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
autoChainName, import { getTool, type Page, type Tool } from "$lib/registry";
createNamedChain, import ChipButton from "$lib/components/ui/ChipButton.svelte";
getChain, import Toggle from "$lib/components/ui/Toggle.svelte";
stashHandoff,
takeHandoff,
updateChainSteps,
} from "$lib/chains.svelte";
import {
createChain,
isStructuralDefault,
type ChainStep,
} from "$lib/pipeline.svelte";
import {
getTool,
type Page,
type Tool,
type ToolImageFile,
} from "$lib/registry";
import {
applySourceDefaults,
clampSourceAwareMaxes,
sanitizeSchemaParams,
} from "$lib/registry-schema";
import { onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import AddStepButton from "./AddStepButton.svelte"; import AddStepButton from "./AddStepButton.svelte";
import SchemaAlignedLayout from "./SchemaAlignedLayout.svelte"; import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte";
import SchemaActions from "./SchemaActions.svelte";
import SchemaFields from "./SchemaFields.svelte"; import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import SortableStepCard from "./SortableStepCard.svelte";
import StepReorderOverlay from "./StepReorderOverlay.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte"; import ToolPickerButton from "./ToolPickerButton.svelte";
import { createChainHost } from "./chain-host.svelte";
import { createOutputControls } from "./output-controls.svelte";
import { hasPreviewResult } from "./schema-preview-model";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte"; import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import { import {
createSchemaToolState, createSchemaToolState,
toolSchemaOf, toolSchemaOf,
} from "./schema-tool-state.svelte"; } from "./schema-tool-state.svelte";
import { createSourceUpload } from "./source-upload.svelte";
import { createStepDnd } from "./step-dnd.svelte";
import { createStepMasks } from "./step-mask.svelte";
interface Props { interface Props {
page: Page; page: Page;
@@ -69,48 +53,21 @@
const inputMode = $derived(tool.input); const inputMode = $derived(tool.input);
let aligned = $state(false); let aligned = $state(false);
let source = $state<PixelImage | null>(null);
// The full named input set (multi-upload); `source` above is the first // Composition root: feature state lives in sibling composables
// image for preview/defaults. Single upload = one element. // (source-upload, output-controls, step-mask, chain-host); execution in
let sourceFiles = $state<ToolImageFile[]>([]); // schema-tool-runner, step state in schema-tool-state.
let textSource = $state(""); const upload = createSourceUpload({
let sourceWarnings = $state<ChainWarning[]>([]); inputMode: () => inputMode,
let initedFor = $state(""); steps: () => stepState.steps,
let format = $state<OutputMime>("image/png"); setSteps: (v) => (stepState.steps = v),
// Lossy-format quality for tools without a quality param in the schema. touchedFor: (key) => stepState.touchedFor(key),
let formatQuality = $state<Record<string, number>>({}); reportError: (e) => runner.reportError(e),
// Optional download size limit (KB); undefined = no limit. clearError: () => (runner.displayError = null),
let limitKb = $state<number | undefined>(undefined);
// Mask preview per step (keyed by step.key): the toggle lives on every
// step result tile, not just the final one.
const maskOnKeys = new SvelteSet<string>();
// Mask preview: recomputed on the main thread per toggled step from that
// step's input; a mask never enters the chain itself.
const maskResults = $derived.by(() => {
const next: Record<string, PixelImage | null> = {};
steps.forEach((step, i) => {
if (!maskOnKeys.has(step.key)) return;
const stepTool = getTool(step.id);
if (!stepTool?.runMask) return;
const input = i === 0 ? source : stepResults[i - 1];
if (!input) return;
// Same contract as executor.ts: params are sanitized against the
// schema with the step input as the source context.
const stepSchema = toolSchemaOf(step);
const params = stepSchema
? sanitizeSchemaParams(stepSchema, step.params, { source: input })
: step.params;
try {
next[step.key] = stepTool.runMask({ params, source: input });
} catch {
next[step.key] = null;
}
});
return next;
}); });
const stepState = createSchemaToolState({ const stepState = createSchemaToolState({
source: () => source, source: () => upload.source,
stepDims: () => runner.stepDims, stepDims: () => runner.stepDims,
clearResults: () => runner.clearResults(), clearResults: () => runner.clearResults(),
}); });
@@ -121,20 +78,36 @@
removeStepAt, removeStepAt,
toggleStep, toggleStep,
replaceStepTool, replaceStepTool,
onStepDrop,
} = stepState; } = stepState;
const steps = $derived(stepState.steps); const steps = $derived(stepState.steps);
const stepDnd = createStepDnd({
onMove: (from, to) => {
stepState.steps = moveStep(stepState.steps, from, to);
},
});
const lastTool = $derived(
(steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ??
tool,
);
const output = createOutputControls({
lastTool: () => lastTool,
steps: () => steps,
result: () => runner.result,
setStepValue,
});
const runner = createSchemaToolRunner({ const runner = createSchemaToolRunner({
pageSlug: () => page.slug, pageSlug: () => page.slug,
schema: () => schema, schema: () => schema,
steps: () => steps, steps: () => steps,
source: () => source, source: () => upload.source,
sourceFiles: () => sourceFiles, sourceFiles: () => upload.sourceFiles,
textSource: () => textSource, textSource: () => upload.textSource,
format: () => format, format: () => output.format,
quality: () => currentQuality, quality: () => output.quality,
limitKb: () => limitKb, limitKb: () => output.limitKb,
lastTool: () => lastTool, lastTool: () => lastTool,
}); });
const { run, download, copyText, downloadText } = runner; const { run, download, copyText, downloadText } = runner;
@@ -150,12 +123,27 @@
const stepFileSets = $derived(runner.stepFileSets); const stepFileSets = $derived(runner.stepFileSets);
const runWarnings = $derived(runner.runWarnings); const runWarnings = $derived(runner.runWarnings);
const displayError = $derived(runner.displayError); const displayError = $derived(runner.displayError);
const allWarnings = $derived([...sourceWarnings, ...runWarnings]); const allWarnings = $derived([...upload.sourceWarnings, ...runWarnings]);
const masks = createStepMasks({
steps: () => steps,
source: () => upload.source,
stepResults: () => runner.stepResults,
});
const host = createChainHost({
page: () => page,
chainId: () => chainId,
schema: () => schema,
lastTool: () => lastTool,
steps: () => steps,
stepState,
source: upload,
output,
resetRun: () => runner.resetForHost(),
clearMasks: () => masks.clear(),
});
const lastTool = $derived(
(steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ??
tool,
);
const resultKind = $derived(lastTool.result ?? "image"); const resultKind = $derived(lastTool.result ?? "image");
// Display kind follows the actual output: a batch run of an image tool // Display kind follows the actual output: a batch run of an image tool
// produces a file set, not a single image. // produces a file set, not a single image.
@@ -167,36 +155,22 @@
const alignedMode = $derived( const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1, aligned && resultKind === "image" && steps.length > 1,
); );
const hasResult = $derived(
const alphaLoss = $derived( hasPreviewResult({
resultKind === "image" && inputMode,
result !== null && resultKind: displayResultKind,
!outputFormatByMime(format).supportsAlpha && result,
hasTransparency(result), fileResult,
textResult,
}),
); );
// If the last step's schema has a quality param (convert tools), it is the
// single source of quality; the dropdown edits the same value.
const qualityParamId = $derived(lastTool.output?.qualityParamId);
const lastParams = $derived(steps.at(-1)?.params); const lastParams = $derived(steps.at(-1)?.params);
const resultNote = $derived( const resultNote = $derived(
result && lastTool.resultNote result && lastTool.resultNote
? lastTool.resultNote(lastParams ?? {}, result) ? lastTool.resultNote(lastParams ?? {}, result)
: null, : null,
); );
const currentQuality = $derived.by(() => {
const setting = outputFormatByMime(format).settings?.quality;
if (!setting) return undefined;
if (qualityParamId && lastParams) return Number(lastParams[qualityParamId]);
return formatQuality[format] ?? setting.default;
});
function setFormatQuality(q: number) {
if (qualityParamId) {
setStepValue(steps.length - 1, qualityParamId, q);
} else {
formatQuality = { ...formatQuality, [format]: q };
}
}
const errorText = $derived.by(() => { const errorText = $derived.by(() => {
const current = displayError; const current = displayError;
@@ -206,200 +180,20 @@
: current.text; : current.text;
}); });
const stepMaskable = $derived(steps.map((s) => !!getTool(s.id)?.runMask));
const stepMaskOn = $derived(steps.map((s) => maskOnKeys.has(s.key)));
const stepMasks = $derived(steps.map((s) => maskResults[s.key] ?? null));
const debouncedRun = debounce(() => run(), 200); const debouncedRun = debounce(() => run(), 200);
function stepTitle(toolId: string): string {
return chainStepTitle(toolId);
}
function init() {
if (!schema) return;
const host = chainId ? `chain:${chainId}` : page.slug;
if (host === initedFor) return;
initedFor = host;
// Invalidate any in-flight run from the previous host.
runner.resetForHost();
if (chainId) {
const handoff = takeHandoff(chainId);
stepState.steps = getChain(chainId)?.steps ?? createChain(page);
source = handoff?.source ?? null;
sourceFiles =
handoff?.sourceFiles ??
(handoff?.source
? [{ name: `${baseName(page.slug)}.png`, image: handoff.source }]
: []);
textSource = handoff?.textSource ?? "";
if (handoff?.format) format = handoff.format;
else format = lastTool.output?.mime ?? "image/png";
limitKb = handoff?.limitKb;
} else {
stepState.steps = createChain(page);
format = lastTool.output?.mime ?? "image/png";
limitKb = undefined;
source = null;
sourceFiles = [];
textSource = "";
}
sourceWarnings = [];
maskOnKeys.clear();
stepState.clearMarks();
}
async function handleFiles(files: File[]) {
runner.displayError = null;
sourceWarnings = [];
const decoded: ToolImageFile[] = [];
const usedNames = new SvelteSet<string>();
let skipped = 0;
for (const file of files) {
try {
const name = uniqueName(`${baseName(file.name)}.png`, usedNames);
usedNames.add(name);
decoded.push({ name, image: await decodeFile(file) });
} catch {
skipped++;
}
}
if (decoded.length === 0) {
runner.reportError(new ToolError("errors.imageDecode"));
return;
}
if (skipped > 0) {
sourceWarnings = [{ kind: "sourceSkip", skipped, total: files.length }];
}
const firstImage = decoded[0].image;
source = firstImage;
sourceFiles = decoded;
const first = steps[0];
const firstSchema = first ? toolSchemaOf(first) : undefined;
if (first && firstSchema) {
stepState.steps = steps.with(0, {
...first,
params: clampSourceAwareMaxes(
firstSchema,
applySourceDefaults(
firstSchema,
first.params,
{ source: firstImage },
stepState.touchedFor(first.key),
),
firstImage,
),
});
}
}
async function handleFile(file: File) {
await handleFiles([file]);
}
$effect(() => { $effect(() => {
init(); if (!host.inited) return;
}); if (inputMode === "image" && !upload.source) return;
if (inputMode === "text" && !upload.textSource.trim()) return;
// Named-chain mode autosaves into the chains store; a plain tool page
// persists nothing -- it becomes a named chain on structural change below.
$effect(() => {
if (!initedFor || !chainId) return;
void steps; void steps;
updateChainSteps(chainId, steps); void upload.source;
});
// A tool page turns into a pipeline on the first structural change (step
// added, first tool swapped): create the named chain and swap routes
// seamlessly -- steps persist via the store, the source via the handoff.
$effect(() => {
if (!initedFor || chainId) return;
void steps;
if (isStructuralDefault(page, steps)) return;
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, {
source,
sourceFiles,
textSource,
format,
limitKb,
});
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
});
onMount(() => {
if (inputMode !== "image") return;
function onPaste(e: ClipboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest("input, textarea, [contenteditable]")) return;
for (const item of e.clipboardData?.items ?? []) {
if (item.kind !== "file" || !item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) {
e.preventDefault();
void handleFile(file);
}
return;
}
}
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
});
$effect(() => {
if (!initedFor) return;
if (inputMode === "image" && !source) return;
if (inputMode === "text" && !textSource.trim()) return;
void steps;
void source;
debouncedRun(); debouncedRun();
return () => debouncedRun.cancel(); return () => debouncedRun.cancel();
}); });
function toggleStepMask(key: string) { function stepTitle(toolId: string): string {
if (maskOnKeys.has(key)) maskOnKeys.delete(key); return chainStepTitle(toolId);
else maskOnKeys.add(key);
}
function toggleStepMaskByIndex(i: number) {
const step = steps[i];
if (step) toggleStepMask(step.key);
}
function warningText(w: ChainWarning): string {
switch (w.kind) {
case "partial":
return t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total });
case "firstOnly":
return t("chain.warnFirstOnly", { n: w.step, total: w.total });
case "sourceSkip":
return t("chain.warnSourceSkip", {
skipped: w.skipped,
total: w.total,
});
}
}
// Explicit save for a structurally default chain (single tuned step): the
// auto-create effect only fires on structural change.
function saveAsChain() {
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, {
source,
sourceFiles,
textSource,
format,
limitKb,
});
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
} }
</script> </script>
@@ -434,32 +228,19 @@
label={t("savedChains.saveAs")} label={t("savedChains.saveAs")}
variant={ButtonVariantDefine.OUTLINE} variant={ButtonVariantDefine.OUTLINE}
size="s" size="s"
onclick={saveAsChain} onclick={host.saveAsChain}
/> />
{/if} {/if}
</header> </header>
{#snippet stepCard(step: ChainStep, i: number)} {#snippet stepCard(step: ChainStep, i: number)}
<StepCard <SortableStepCard
index={i + 1} {step}
index={i}
title={stepTitle(step.id)} title={stepTitle(step.id)}
draggable
collapsed={step.collapsed ?? false} collapsed={step.collapsed ?? false}
ontoggle={() => toggleStep(i)} ontoggle={() => toggleStep(i)}
onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined} onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined}
ondragstart={(e) => {
stepState.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={() => (stepState.dragFrom = null)}
> >
{#snippet tools()} {#snippet tools()}
<ToolPickerButton <ToolPickerButton
@@ -474,127 +255,196 @@
schema={stepSchema} schema={stepSchema}
values={step.params} values={step.params}
toolId={step.id} toolId={step.id}
sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]} sourceDims={i === 0
? (upload.source ?? undefined)
: stepDims[i - 1]}
onchange={(id, v, axis) => setStepValue(i, id, v, axis)} onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
onreset={() => resetStep(i)} onreset={() => resetStep(i)}
/> />
{/if} {/if}
</StepCard> </SortableStepCard>
{/snippet} {/snippet}
{#if !alignedMode} {#snippet maskChip(i: number)}
<div class="workspace"> {#if masks.maskable[i]}
<section class="settings"> <ChipButton
{#if canExtend} label={t("resultCard.maskToggle")}
<AddStepButton onadd={(id) => addStepAt(0, id)} /> active={masks.maskOn[i] ?? false}
onclick={() => masks.toggleByIndex(i)}
/>
{/if}
{/snippet}
{#snippet inputTile(i: number)}
{#if i === 0}
<SchemaSourceTile
mode={inputMode}
source={upload.source}
sources={upload.sourceFiles}
textSource={upload.textSource}
running={busy}
fileCount={upload.sourceFiles.length}
ontextinput={(v) => (upload.textSource = v)}
onrendertext={run}
onupload={upload.handleFile}
onuploadmany={upload.handleFiles}
/>
{:else}
{@const input = stepResults[i - 1]}
{@const inSet = stepFileSets[i - 1] ?? []}
<PreviewTile
label={t("chain.inputLegend")}
viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined}
parts={inSet.length > 1 ? inSet.length : undefined}
>
{#if inSet.length > 1}
<PartsGrid files={inSet} />
{:else if input}
<img src={cachedDataUrl(input)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if} {/if}
</PreviewTile>
{/if}
{/snippet}
{#snippet outputTile(i: number)}
{@const out = stepResults[i]}
{#if i === steps.length - 1}
<SchemaResultTile
resultKind={displayResultKind}
result={out}
{resultNote}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
running={busy}
mask={masks.masks[i] ?? null}
maskOn={masks.maskOn[i] ?? false}
ontogglemask={masks.maskable[i]
? () => masks.toggleByIndex(i)
: undefined}
oncopy={copyText}
ondownloadtxt={downloadText}
/>
{:else}
{@const outSet = stepFileSets[i] ?? []}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
parts={outSet.length > 1 ? outSet.length : undefined}
>
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if outSet.length > 1 && !masks.maskOn[i]}
<PartsGrid files={outSet} />
{:else if out}
<img
src={cachedDataUrl(
masks.maskOn[i] && masks.masks[i]
? (masks.masks[i] as PixelImage)
: out,
)}
alt=""
/>
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
{/snippet}
{#snippet panelHead()}
<div class="panel-head">
<span class="label">{t("resultCard.previewPanel")}</span>
{#if resultKind === "image" && steps.length > 1}
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
{/if}
<SchemaActions
{inputMode}
resultKind={displayResultKind}
canDownload={hasResult}
running={busy}
format={output.format}
quality={output.quality}
limitKb={output.limitKb}
alphaLoss={output.alphaLoss}
onupload={upload.handleFile}
ondownload={download}
onformat={(v) => (output.format = v)}
onquality={output.setQuality}
onlimit={(v) => (output.limitKb = v)}
/>
</div>
{/snippet}
{#snippet addRow(index: number)}
{#if canExtend}
<div class="add-row">
<AddStepButton onadd={(id) => addStepAt(index, id)} />
{#if alignedMode}
<div class="row-bridge" aria-hidden="true"></div>
{/if}
</div>
{/if}
{/snippet}
<DragDropProvider
onDragStart={stepDnd.onDragStart}
onDragMove={stepDnd.onDragOver}
onDragOver={stepDnd.onDragOver}
onDragEnd={stepDnd.onDragEnd}
>
{#if alignedMode}
<div class="workspace aligned">
<div class="head-spacer" aria-hidden="true"></div>
{@render panelHead()}
{@render addRow(0)}
{#each steps as step, i (step.key)} {#each steps as step, i (step.key)}
{@render stepCard(step, i)} {@render stepCard(step, i)}
{#if canExtend} <div class="pair">
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} /> {@render inputTile(i)}
{/if} {@render outputTile(i)}
</div>
{@render addRow(i + 1)}
{/each} {/each}
</section> <div class="panel-foot" aria-hidden="true"></div>
</div>
<section class="panel"> {:else}
<SchemaPreview <div class="workspace">
toolId={lastTool.id} <section class="settings">
{inputMode} {@render addRow(0)}
resultKind={displayResultKind} {#each steps as step, i (step.key)}
running={busy} {@render stepCard(step, i)}
error={errorText} {@render addRow(i + 1)}
bind:aligned {/each}
head={{ </section>
format, <section class="panel">
quality: currentQuality, {@render panelHead()}
limitKb, <div class="pair-grid">
alphaLoss, <div class="cell">{@render inputTile(0)}</div>
onupload: handleFile, {#each steps as step, i (step.key)}
ondownload: download, <div class="cell">{@render outputTile(i)}</div>
onformat: (v) => (format = v), {/each}
onquality: setFormatQuality, </div>
onlimit: (v) => (limitKb = v), </section>
}} </div>
source={{ {/if}
source, {#if errorText}
sources: sourceFiles, <p class="error" role="alert">{errorText}</p>
fileCount: sourceFiles.length, {/if}
textSource, {#each allWarnings as w (chainWarningText(w))}
ontextinput: (v) => (textSource = v), <p class="warn" role="status">{chainWarningText(w)}</p>
onrendertext: run,
onuploadmany: handleFiles,
}}
steps={{
results: stepResults,
fileSets: stepFileSets,
maskable: stepMaskable,
maskOn: stepMaskOn,
masks: stepMasks,
ontogglestepmask: toggleStepMaskByIndex,
}}
result={{
result,
resultNote,
fileResult,
textResult,
textVars: verdictVars,
mask: stepMasks.at(-1) ?? null,
maskOn: stepMaskOn.at(-1) ?? false,
ontogglemask: stepMaskable.at(-1)
? () => toggleStepMask(steps[steps.length - 1].key)
: undefined,
oncopy: copyText,
ondownloadtxt: downloadText,
}}
/>
</section>
</div>
{#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p>
{/each} {/each}
{:else} <StepReorderOverlay {steps} dnd={stepDnd} />
<SchemaAlignedLayout </DragDropProvider>
{steps}
{stepCard}
{canExtend}
bind:aligned
{inputMode}
{source}
{sourceFiles}
{textSource}
running={busy}
resultKind={displayResultKind}
{result}
{resultNote}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
{format}
quality={currentQuality}
{limitKb}
{alphaLoss}
{errorText}
warnings={allWarnings.map(warningText)}
{stepResults}
{stepFileSets}
{stepMasks}
{stepMaskOn}
{stepMaskable}
onaddstep={addStepAt}
ontogglestepmask={toggleStepMaskByIndex}
ontextinput={(v) => (textSource = v)}
onrendertext={run}
onupload={handleFile}
onuploadmany={handleFiles}
ondownload={download}
oncopy={copyText}
ondownloadtxt={downloadText}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
/>
{/if}
</div> </div>
{:else} {:else}
<p class="no-schema">{t("paramsCard.noSchema")}</p> <p class="no-schema">{t("paramsCard.noSchema")}</p>
@@ -641,6 +491,11 @@
font-size: var(--font-size-s); font-size: var(--font-size-s);
line-height: 1.5; line-height: 1.5;
} }
/* Two scaffoldings over shared snippets: normal mode keeps two independent
columns (cards pack under each other, tiles pack two per row inside one
panel); aligned mode is a flat 3-column grid where each card shares its
grid row with the input/output pair and the right side reads as one
continuous panel. */
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) minmax( grid-template-columns: minmax(var(--size-workspace-min), 1fr) minmax(
@@ -650,23 +505,143 @@
gap: var(--space-xxl); gap: var(--space-xxl);
align-items: start; align-items: start;
} }
.settings {
display: flex;
flex-direction: column;
}
.panel { .panel {
padding: var(--space-xl);
border: var(--size-border) solid var(--color-border); border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m); border-radius: var(--radius-m);
background: var(--color-panel); background: var(--color-panel);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.pair-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
.cell {
min-width: 0;
}
/* Aligned mode: cards in column 1, each step's input/output pair spans
columns 2-3 on the card's row. The right side forms one panel: head and
foot carry the top/bottom borders, pairs and row bridges carry the side
borders, so rows have no vertical gaps. */
.workspace.aligned {
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 1fr 1fr;
gap: 0 var(--space-xxl);
}
.aligned > :global(.step-card) {
grid-column: 1;
}
.aligned .head-spacer {
grid-column: 1;
}
.aligned .panel-head {
grid-column: 2 / -1;
padding: var(--space-l) var(--space-xl);
border: var(--size-border) solid var(--color-border);
border-bottom: none;
border-radius: var(--radius-m) var(--radius-m) 0 0;
background: var(--color-panel);
margin-bottom: 0;
}
.aligned .pair {
grid-column: 2 / -1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
padding: var(--space-xl); padding: var(--space-xl);
border-right: var(--size-border) solid var(--color-border);
border-left: var(--size-border) solid var(--color-border);
background: var(--color-panel);
}
/* The workspace has align-items: start; panel pieces must stretch to the
row height or the side borders break when a card is taller than its
pair. */
.aligned .panel-head,
.aligned .pair,
.aligned .row-bridge,
.aligned .panel-foot {
align-self: stretch;
}
.aligned .add-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
}
.aligned .row-bridge {
grid-column: 2 / -1;
border-right: var(--size-border) solid var(--color-border);
border-left: var(--size-border) solid var(--color-border);
background: var(--color-panel);
}
.aligned .panel-foot {
grid-column: 2 / -1;
min-height: var(--space-xl);
border: var(--size-border) solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-m) var(--radius-m);
background: var(--color-panel);
}
.error {
margin: var(--space-l) 0 0;
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.empty {
font: var(--font-size-s) var(--font-mono);
} }
.no-schema { .no-schema {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
.warn { .warn {
margin: 0; margin: var(--space-s) 0 0;
color: var(--color-warning); color: var(--color-warning);
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
@media (--bp-tablet) { @media (--bp-tablet) {
.workspace { .workspace,
.workspace.aligned {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.pair-grid,
.aligned .pair {
grid-template-columns: 1fr;
}
.aligned .head-spacer,
.aligned .row-bridge {
display: none;
}
.aligned .panel-head,
.aligned .pair,
.aligned .panel-foot {
grid-column: 1 / -1;
}
} }
</style> </style>
@@ -0,0 +1,58 @@
<script lang="ts">
import StepCard from "$lib/components/display/StepCard.svelte";
import type { ChainStep } from "$lib/pipeline.svelte";
import { createSortable } from "@dnd-kit/svelte/sortable";
import type { Snippet } from "svelte";
interface Props {
step: ChainStep;
index: number;
title: string;
collapsed?: boolean;
tools?: Snippet;
children?: Snippet;
onremove?: () => void;
ontoggle?: () => void;
}
let {
step,
index,
title,
collapsed = false,
tools,
children,
onremove,
ontoggle,
}: Props = $props();
// feedback: "none" -- no transform shifting; step-dnd.svelte.ts hides the
// source and renders a dashed drop indicator instead.
const sortable = createSortable({
get id() {
return step.key;
},
get index() {
return index;
},
group: "steps",
type: "step",
accept: "step",
feedback: "none",
get data() {
return { title };
},
});
</script>
<StepCard
index={index + 1}
{title}
{collapsed}
{tools}
{children}
{onremove}
{ontoggle}
elementAttachment={sortable.attach}
handleAttachment={sortable.attachHandle}
/>
@@ -0,0 +1,125 @@
<script lang="ts">
import { chainStepTitle } from "$lib/i18n/schema-tool-strings";
import type { ChainStep } from "$lib/pipeline.svelte";
import { GripVertical } from "@lucide/svelte";
import type { StepDnd } from "./step-dnd.svelte";
interface Props {
steps: ChainStep[];
dnd: StepDnd;
}
let { steps, dnd }: Props = $props();
let listEl = $state<HTMLElement | null>(null);
$effect(() => {
dnd.registerList(listEl);
return () => dnd.registerList(null);
});
// Align the overlay so its source row sits exactly under the cursor (the
// dragged card's grab point); the page behind never moves.
const top = $derived.by(() => {
const anchor = dnd.anchor;
const el = listEl;
if (!anchor || !el || steps.length === 0) return null;
const rowH = el.scrollHeight / steps.length;
const offsetInSource = anchor.startY - anchor.sourceTop;
const raw = anchor.startY - (dnd.sourceIndex ?? 0) * rowH - offsetInSource;
const margin = 16;
const maxTop = window.innerHeight - el.offsetHeight - margin;
return Math.min(Math.max(raw, margin), Math.max(margin, maxTop));
});
const lineTop = $derived.by(() => {
const slot = dnd.indicatorSlot;
const el = listEl;
if (slot === null || !el || steps.length === 0) return null;
const rowH = el.scrollHeight / steps.length;
return slot * rowH - el.scrollTop;
});
</script>
{#if dnd.activeData && dnd.anchor}
<div class="reorder-scrim" aria-hidden="true"></div>
<div
class="reorder-overlay"
style:left="{dnd.anchor.left}px"
style:top="{top ?? dnd.anchor.sourceTop}px"
style:width="{dnd.anchor.width}px"
style:visibility={top === null ? "hidden" : "visible"}
>
<ol class="reorder-list" bind:this={listEl}>
{#each steps as step, i (step.key)}
<li class:dimmed={i === dnd.sourceIndex}>
<GripVertical size={14} aria-hidden="true" />
<span class="reorder-index">{String(i + 1).padStart(2, "0")}</span>
<span class="reorder-title">{chainStepTitle(step.id)}</span>
</li>
{/each}
</ol>
{#if lineTop !== null}
<div class="reorder-line" style:top="{lineTop}px"></div>
{/if}
</div>
{/if}
<style>
/* The scrim dims the page so the floating list does not blend into the
step cards underneath; it never intercepts the drag. */
.reorder-scrim {
position: fixed;
inset: 0;
z-index: var(--z-dropdown);
background: var(--color-scrim);
pointer-events: none;
}
.reorder-overlay {
position: fixed;
z-index: var(--z-drag);
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 60vh;
border: var(--size-border-thick) solid var(--color-main);
background: var(--color-panel);
}
.reorder-list {
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.reorder-list li {
display: flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-s) var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
color: var(--color-border);
white-space: nowrap;
}
.reorder-list li:last-child {
border-bottom: none;
}
.reorder-list li.dimmed {
opacity: 0.45;
}
.reorder-index {
font: var(--font-size-s) var(--font-mono);
color: var(--color-main);
}
.reorder-title {
overflow: hidden;
text-overflow: ellipsis;
font: 600 var(--font-size-m) var(--font-mono);
color: var(--color-text);
}
.reorder-line {
position: absolute;
right: 0;
left: 0;
height: var(--size-border-thick);
background: var(--color-main);
pointer-events: none;
}
</style>
@@ -0,0 +1,139 @@
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import {
autoChainName,
createNamedChain,
getChain,
stashHandoff,
takeHandoff,
updateChainSteps,
} from "$lib/chains.svelte";
import type { OutputMime } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import {
createChain,
isStructuralDefault,
type ChainStep,
} from "$lib/pipeline.svelte";
import type { Page, Tool, ToolImageFile } from "$lib/registry";
import type { ToolSchema } from "$lib/registry-schema";
import { baseName } from "$lib/run-chain";
interface SourceLike {
source: PixelImage | null;
sourceFiles: ToolImageFile[];
textSource: string;
clearWarnings: () => void;
}
interface OutputLike {
format: OutputMime;
limitKb: number | undefined;
}
export interface ChainHostCtx {
page: () => Page;
chainId: () => string | undefined;
schema: () => ToolSchema<Record<string, unknown>> | undefined;
lastTool: () => Tool;
steps: () => ChainStep[];
stepState: { steps: ChainStep[]; clearMarks: () => void };
source: SourceLike;
output: OutputLike;
// Drops run state and invalidates any in-flight run from the previous host.
resetRun: () => void;
clearMasks: () => void;
}
// Host lifecycle of SchemaToolView: per-host init (plain tool page vs named
// chain), handoff across the tool -> pipeline route swap, autosave into the
// chains store and the explicit "save as chain" action.
export function createChainHost(ctx: ChainHostCtx) {
let initedFor = $state("");
function init() {
if (!ctx.schema()) return;
const chainId = ctx.chainId();
const host = chainId ? `chain:${chainId}` : ctx.page().slug;
if (host === initedFor) return;
initedFor = host;
ctx.resetRun();
if (chainId) {
const handoff = takeHandoff(chainId);
ctx.stepState.steps = getChain(chainId)?.steps ?? createChain(ctx.page());
ctx.source.source = handoff?.source ?? null;
ctx.source.sourceFiles =
handoff?.sourceFiles ??
(handoff?.source
? [
{
name: `${baseName(ctx.page().slug)}.png`,
image: handoff.source,
},
]
: []);
ctx.source.textSource = handoff?.textSource ?? "";
ctx.output.format =
handoff?.format ?? ctx.lastTool().output?.mime ?? "image/png";
ctx.output.limitKb = handoff?.limitKb;
} else {
ctx.stepState.steps = createChain(ctx.page());
ctx.output.format = ctx.lastTool().output?.mime ?? "image/png";
ctx.output.limitKb = undefined;
ctx.source.source = null;
ctx.source.sourceFiles = [];
ctx.source.textSource = "";
}
ctx.source.clearWarnings();
ctx.clearMasks();
ctx.stepState.clearMarks();
}
function openChain(steps: ChainStep[]) {
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, {
source: ctx.source.source,
sourceFiles: ctx.source.sourceFiles,
textSource: ctx.source.textSource,
format: ctx.output.format,
limitKb: ctx.output.limitKb,
});
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}
$effect(() => {
init();
});
// Named-chain mode autosaves into the chains store; a plain tool page
// persists nothing -- it becomes a named chain on structural change below.
$effect(() => {
const chainId = ctx.chainId();
if (!initedFor || !chainId) return;
void ctx.steps();
updateChainSteps(chainId, ctx.steps());
});
// A tool page turns into a pipeline on the first structural change (step
// added, first tool swapped): create the named chain and swap routes
// seamlessly -- steps persist via the store, the source via the handoff.
$effect(() => {
if (!initedFor || ctx.chainId()) return;
void ctx.steps();
if (isStructuralDefault(ctx.page(), ctx.steps())) return;
openChain(ctx.steps());
});
return {
get inited() {
return initedFor !== "";
},
// Explicit save for a structurally default chain (single tuned step):
// the auto-create effect only fires on structural change.
saveAsChain: () => openChain(ctx.steps()),
};
}
@@ -0,0 +1,71 @@
import { hasTransparency } from "$lib/core/analyze";
import type { OutputMime } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { outputFormatByMime } from "$lib/output-formats";
import type { ChainStep } from "$lib/pipeline.svelte";
import type { Tool } from "$lib/registry";
export interface OutputControlsCtx {
lastTool: () => Tool;
steps: () => ChainStep[];
result: () => PixelImage | null;
setStepValue: (index: number, id: string, value: unknown) => void;
}
// Download controls of SchemaToolView: output format, lossy quality and the
// optional size limit, plus the alpha-loss warning for the current format.
export function createOutputControls(ctx: OutputControlsCtx) {
let format = $state<OutputMime>("image/png");
// Lossy-format quality for tools without a quality param in the schema.
let formatQuality = $state<Record<string, number>>({});
// Optional download size limit (KB); undefined = no limit.
let limitKb = $state<number | undefined>(undefined);
// If the last step's schema has a quality param (convert tools), it is the
// single source of quality; the dropdown edits the same value.
const qualityParamId = $derived(ctx.lastTool().output?.qualityParamId);
const lastParams = $derived(ctx.steps().at(-1)?.params);
const quality = $derived.by(() => {
const setting = outputFormatByMime(format).settings?.quality;
if (!setting) return undefined;
if (qualityParamId && lastParams) return Number(lastParams[qualityParamId]);
return formatQuality[format] ?? setting.default;
});
const alphaLoss = $derived.by(() => {
const result = ctx.result();
return (
(ctx.lastTool().result ?? "image") === "image" &&
result !== null &&
!outputFormatByMime(format).supportsAlpha &&
hasTransparency(result)
);
});
return {
get format() {
return format;
},
set format(v: OutputMime) {
format = v;
},
get quality() {
return quality;
},
get limitKb() {
return limitKb;
},
set limitKb(v: number | undefined) {
limitKb = v;
},
get alphaLoss() {
return alphaLoss;
},
setQuality: (q: number) => {
if (qualityParamId) {
ctx.setStepValue(ctx.steps().length - 1, qualityParamId, q);
} else {
formatQuality = { ...formatQuality, [format]: q };
}
},
};
}
@@ -2,7 +2,6 @@ import type { PixelImage } from "$lib/core/types";
import { import {
createStep, createStep,
insertStep, insertStep,
moveStep,
removeStep, removeStep,
type ChainStep, type ChainStep,
} from "$lib/pipeline.svelte"; } from "$lib/pipeline.svelte";
@@ -23,7 +22,6 @@ export interface SchemaToolStateCtx {
interface StepsState { interface StepsState {
steps: ChainStep[]; steps: ChainStep[];
dragFrom: number | null;
} }
export function toolSchemaOf(step: ChainStep) { export function toolSchemaOf(step: ChainStep) {
@@ -122,7 +120,7 @@ function touchedFor(marks: Marks, key: string): SvelteSet<string> {
// Chain-step state of SchemaToolView: the steps array, per-step edit marks // Chain-step state of SchemaToolView: the steps array, per-step edit marks
// and all mutations. Results/execution live in schema-tool-runner.svelte.ts. // and all mutations. Results/execution live in schema-tool-runner.svelte.ts.
export function createSchemaToolState(ctx: SchemaToolStateCtx) { export function createSchemaToolState(ctx: SchemaToolStateCtx) {
const s = $state<StepsState>({ steps: [], dragFrom: null }); const s = $state<StepsState>({ steps: [] });
const marks: Marks = { const marks: Marks = {
touchedByStep: new SvelteMap(), touchedByStep: new SvelteMap(),
lastAxis: {}, lastAxis: {},
@@ -134,12 +132,6 @@ export function createSchemaToolState(ctx: SchemaToolStateCtx) {
set steps(v: ChainStep[]) { set steps(v: ChainStep[]) {
s.steps = v; s.steps = v;
}, },
get dragFrom() {
return s.dragFrom;
},
set dragFrom(v: number | null) {
s.dragFrom = v;
},
touchedFor: (key: string) => touchedFor(marks, key), touchedFor: (key: string) => touchedFor(marks, key),
// Marks are keyed by step.key from the previous host; keep them and // Marks are keyed by step.key from the previous host; keep them and
// stale keys leak into the new chain's fields. // stale keys leak into the new chain's fields.
@@ -181,10 +173,5 @@ export function createSchemaToolState(ctx: SchemaToolStateCtx) {
collapsed: step.collapsed, collapsed: step.collapsed,
}); });
}, },
onStepDrop: (e: DragEvent, to: number) => {
e.preventDefault();
if (s.dragFrom !== null) s.steps = moveStep(s.steps, s.dragFrom, to);
s.dragFrom = null;
},
}; };
} }
@@ -0,0 +1,130 @@
import { ToolError } from "$lib/core/errors";
import { decodeFile } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import type { ChainStep } from "$lib/pipeline.svelte";
import type { InputMode, ToolImageFile } from "$lib/registry";
import {
applySourceDefaults,
clampSourceAwareMaxes,
} from "$lib/registry-schema";
import { baseName, uniqueName, type ChainWarning } from "$lib/run-chain";
import { onMount } from "svelte";
import type { SvelteSet } from "svelte/reactivity";
import { toolSchemaOf } from "./schema-tool-state.svelte";
export interface SourceUploadCtx {
inputMode: () => InputMode;
steps: () => ChainStep[];
setSteps: (steps: ChainStep[]) => void;
touchedFor: (key: string) => SvelteSet<string>;
reportError: (e: unknown) => void;
clearError: () => void;
}
// Source state of SchemaToolView: image files / text input, decode warnings,
// paste-to-load. `source` is the first image for preview/defaults;
// `sourceFiles` is the full named input set (single upload = one element).
export function createSourceUpload(ctx: SourceUploadCtx) {
let source = $state<PixelImage | null>(null);
let sourceFiles = $state<ToolImageFile[]>([]);
let textSource = $state("");
let sourceWarnings = $state<ChainWarning[]>([]);
async function handleFiles(files: File[]) {
ctx.clearError();
sourceWarnings = [];
const decoded: ToolImageFile[] = [];
const usedNames = new Set<string>();
let skipped = 0;
for (const file of files) {
try {
const name = uniqueName(`${baseName(file.name)}.png`, usedNames);
usedNames.add(name);
decoded.push({ name, image: await decodeFile(file) });
} catch {
skipped++;
}
}
if (decoded.length === 0) {
ctx.reportError(new ToolError("errors.imageDecode"));
return;
}
if (skipped > 0) {
sourceWarnings = [{ kind: "sourceSkip", skipped, total: files.length }];
}
const firstImage = decoded[0].image;
source = firstImage;
sourceFiles = decoded;
const first = ctx.steps()[0];
const firstSchema = first ? toolSchemaOf(first) : undefined;
if (first && firstSchema) {
ctx.setSteps(
ctx.steps().with(0, {
...first,
params: clampSourceAwareMaxes(
firstSchema,
applySourceDefaults(
firstSchema,
first.params,
{ source: firstImage },
ctx.touchedFor(first.key),
),
firstImage,
),
}),
);
}
}
async function handleFile(file: File) {
await handleFiles([file]);
}
onMount(() => {
if (ctx.inputMode() !== "image") return;
function onPaste(e: ClipboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest("input, textarea, [contenteditable]")) return;
for (const item of e.clipboardData?.items ?? []) {
if (item.kind !== "file" || !item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) {
e.preventDefault();
void handleFile(file);
}
return;
}
}
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
});
return {
get source() {
return source;
},
set source(v: PixelImage | null) {
source = v;
},
get sourceFiles() {
return sourceFiles;
},
set sourceFiles(v: ToolImageFile[]) {
sourceFiles = v;
},
get textSource() {
return textSource;
},
set textSource(v: string) {
textSource = v;
},
get sourceWarnings() {
return sourceWarnings;
},
clearWarnings: () => {
sourceWarnings = [];
},
handleFile,
handleFiles,
};
}
@@ -0,0 +1,122 @@
import type { Draggable } from "@dnd-kit/dom";
import { isSortable } from "@dnd-kit/dom/sortable";
export interface StepDragData {
title: string;
}
interface StepDndOptions {
// Compressed-index semantics: `to` counts items with the source excluded,
// matching moveStep's remove-then-insert.
onMove: (from: number, to: number) => void;
}
interface DndEvent {
operation?: {
source?: Draggable | null;
position?: { current?: { x: number; y: number } } | null;
};
canceled?: boolean;
}
export interface StepDragAnchor {
left: number;
width: number;
// Pointer Y and the source card's top at drag start: the overlay aligns
// its source row under the cursor.
startY: number;
sourceTop: number;
}
// Step-reorder state for SchemaToolView. Dragging a card handle opens a
// compact overlay list (StepReorderOverlay) instead of reshaping the page:
// the page keeps its layout and scroll position, drop targeting is computed
// against the overlay rows.
export function createStepDnd({ onMove }: StepDndOptions) {
let activeData = $state<StepDragData | null>(null);
let indicatorSlot = $state<number | null>(null);
let sourceIndex = $state<number | null>(null);
let anchor = $state<StepDragAnchor | null>(null);
let listEl: HTMLElement | null = null;
// Slot in full-list coordinates: how many overlay rows have their midpoint
// above the pointer. Rows are uniform single-line height.
function slotAt(position: { x: number; y: number }): number | null {
const el = listEl;
if (!el || el.childElementCount === 0) return null;
const rect = el.getBoundingClientRect();
if (position.x < rect.left || position.x > rect.right) return null;
const count = el.childElementCount;
const rowH = el.scrollHeight / count;
if (rowH <= 0) return null;
const offsetY = position.y - rect.top + el.scrollTop;
const slot = Math.floor(offsetY / rowH + 0.5);
return Math.min(Math.max(slot, 0), count);
}
function onDragStart(event: DndEvent) {
const source = event.operation?.source;
if (!source) return;
activeData = (source.data as StepDragData | undefined) ?? null;
if (isSortable(source)) sourceIndex = source.sortable.initialIndex;
const el = source.element as HTMLElement | undefined;
const rect = el?.getBoundingClientRect();
const position = event.operation?.position?.current;
if (rect) {
anchor = {
left: rect.left,
width: rect.width,
startY: position?.y ?? rect.top + rect.height / 2,
sourceTop: rect.top,
};
}
}
function onDragOver(event: DndEvent) {
const position = event.operation?.position?.current;
indicatorSlot = position ? slotAt(position) : null;
}
function onDragEnd(event: DndEvent) {
const position = event.operation?.position?.current;
const slot = event.canceled || !position ? null : slotAt(position);
const from = sourceIndex;
activeData = null;
indicatorSlot = null;
sourceIndex = null;
anchor = null;
if (from === null || slot === null) return;
// Slots include the source row; moveStep wants the compressed index.
const to = slot > from ? slot - 1 : slot;
if (to === from) return;
onMove(from, to);
}
function registerList(el: HTMLElement | null) {
listEl = el;
}
return {
get activeData() {
return activeData;
},
get active() {
return activeData !== null;
},
get indicatorSlot() {
return indicatorSlot;
},
get sourceIndex() {
return sourceIndex;
},
get anchor() {
return anchor;
},
onDragStart,
onDragOver,
onDragEnd,
registerList,
};
}
export type StepDnd = ReturnType<typeof createStepDnd>;
@@ -0,0 +1,59 @@
import type { PixelImage } from "$lib/core/types";
import type { ChainStep } from "$lib/pipeline.svelte";
import { getTool } from "$lib/registry";
import { sanitizeSchemaParams } from "$lib/registry-schema";
import { SvelteSet } from "svelte/reactivity";
import { toolSchemaOf } from "./schema-tool-state.svelte";
export interface StepMasksCtx {
steps: () => ChainStep[];
source: () => PixelImage | null;
stepResults: () => (PixelImage | null)[];
}
// Per-step mask preview of SchemaToolView: the toggle lives on every step
// result tile (keyed by step.key), the mask never enters the chain itself.
export function createStepMasks(ctx: StepMasksCtx) {
const onKeys = new SvelteSet<string>();
// Recomputed on the main thread per toggled step from that step's input.
const results = $derived.by(() => {
const next: Record<string, PixelImage | null> = {};
ctx.steps().forEach((step, i) => {
if (!onKeys.has(step.key)) return;
const stepTool = getTool(step.id);
if (!stepTool?.runMask) return;
const input = i === 0 ? ctx.source() : ctx.stepResults()[i - 1];
if (!input) return;
// Same contract as executor.ts: params are sanitized against the
// schema with the step input as the source context.
const stepSchema = toolSchemaOf(step);
const params = stepSchema
? sanitizeSchemaParams(stepSchema, step.params, { source: input })
: step.params;
try {
next[step.key] = stepTool.runMask({ params, source: input });
} catch {
next[step.key] = null;
}
});
return next;
});
return {
get maskable() {
return ctx.steps().map((s) => !!getTool(s.id)?.runMask);
},
get maskOn() {
return ctx.steps().map((s) => onKeys.has(s.key));
},
get masks() {
return ctx.steps().map((s) => results[s.key] ?? null);
},
toggleByIndex: (i: number) => {
const step = ctx.steps()[i];
if (!step) return;
if (onKeys.has(step.key)) onKeys.delete(step.key);
else onKeys.add(step.key);
},
clear: () => onKeys.clear(),
};
}
+15
View File
@@ -82,6 +82,21 @@ export function toBase64(img: PixelImage): string {
return toDataUrl(img).slice("data:image/png;base64,".length); return toDataUrl(img).slice("data:image/png;base64,".length);
} }
const dataUrlCache = new WeakMap<PixelImage, string>();
// Display helper: PNG-encode on the main thread is expensive and templates
// re-render often with the same image (layout branch swaps, toggles). Results
// are treated as immutable -- a re-run produces new objects -- so an
// identity-keyed cache cannot go stale.
export function cachedDataUrl(img: PixelImage): string {
let url = dataUrlCache.get(img);
if (url === undefined) {
url = toDataUrl(img);
dataUrlCache.set(img, url);
}
return url;
}
export async function decodeTextImage(text: string): Promise<PixelImage> { export async function decodeTextImage(text: string): Promise<PixelImage> {
const cleaned = text.trim().replace(/^data:[^,]*,/, ""); const cleaned = text.trim().replace(/^data:[^,]*,/, "");
if (cleaned.length === 0) { if (cleaned.length === 0) {
+16
View File
@@ -1,5 +1,6 @@
import type { Page } from "$lib/registry"; import type { Page } from "$lib/registry";
import { PAGES } from "../registry"; import { PAGES } from "../registry";
import type { ChainWarning } from "$lib/run-chain";
import type { Field } from "$lib/registry-schema"; import type { Field } from "$lib/registry-schema";
import { getMergedDict } from "./locale.svelte"; import { getMergedDict } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching"; import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
@@ -67,6 +68,21 @@ export function verdictTone(key: string): "success" | "danger" | "info" {
return "info"; return "info";
} }
/** Human-readable text for a chain run/source warning. */
export function chainWarningText(w: ChainWarning): string {
switch (w.kind) {
case "partial":
return t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total });
case "firstOnly":
return t("chain.warnFirstOnly", { n: w.step, total: w.total });
case "sourceSkip":
return t("chain.warnSourceSkip", {
skipped: w.skipped,
total: w.total,
});
}
}
function dedupe(values: string[]): string[] { function dedupe(values: string[]): string[] {
return [...new Set(values.filter((v) => v.length > 0))]; return [...new Set(values.filter((v) => v.length > 0))];
} }