mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
Compare commits
6
Commits
8b94feee79
...
81612db1f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81612db1f3 | ||
|
|
5f4c3833a1 | ||
|
|
b7613e8c0f | ||
|
|
dd9cb6d574 | ||
|
|
e866975045 | ||
|
|
7bbcec5ff0 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+74
@@ -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
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<span class="drag-handle" {@attach handleAttachment}>
|
||||||
<GripVertical size={16} class="drag" aria-hidden="true" />
|
<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}
|
||||||
{/if}
|
onclick={() => masks.toggleByIndex(i)}
|
||||||
{#each steps as step, i (step.key)}
|
|
||||||
{@render stepCard(step, i)}
|
|
||||||
{#if canExtend}
|
|
||||||
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<SchemaPreview
|
|
||||||
toolId={lastTool.id}
|
|
||||||
{inputMode}
|
|
||||||
resultKind={displayResultKind}
|
|
||||||
running={busy}
|
|
||||||
error={errorText}
|
|
||||||
bind:aligned
|
|
||||||
head={{
|
|
||||||
format,
|
|
||||||
quality: currentQuality,
|
|
||||||
limitKb,
|
|
||||||
alphaLoss,
|
|
||||||
onupload: handleFile,
|
|
||||||
ondownload: download,
|
|
||||||
onformat: (v) => (format = v),
|
|
||||||
onquality: setFormatQuality,
|
|
||||||
onlimit: (v) => (limitKb = v),
|
|
||||||
}}
|
|
||||||
source={{
|
|
||||||
source,
|
|
||||||
sources: sourceFiles,
|
|
||||||
fileCount: sourceFiles.length,
|
|
||||||
textSource,
|
|
||||||
ontextinput: (v) => (textSource = v),
|
|
||||||
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>
|
{/if}
|
||||||
</div>
|
{/snippet}
|
||||||
{#each allWarnings as w (warningText(w))}
|
|
||||||
<p class="warn" role="status">{warningText(w)}</p>
|
{#snippet inputTile(i: number)}
|
||||||
{/each}
|
{#if i === 0}
|
||||||
{:else}
|
<SchemaSourceTile
|
||||||
<SchemaAlignedLayout
|
mode={inputMode}
|
||||||
{steps}
|
source={upload.source}
|
||||||
{stepCard}
|
sources={upload.sourceFiles}
|
||||||
{canExtend}
|
textSource={upload.textSource}
|
||||||
bind:aligned
|
|
||||||
{inputMode}
|
|
||||||
{source}
|
|
||||||
{sourceFiles}
|
|
||||||
{textSource}
|
|
||||||
running={busy}
|
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}
|
||||||
|
</PreviewTile>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet outputTile(i: number)}
|
||||||
|
{@const out = stepResults[i]}
|
||||||
|
{#if i === steps.length - 1}
|
||||||
|
<SchemaResultTile
|
||||||
resultKind={displayResultKind}
|
resultKind={displayResultKind}
|
||||||
{result}
|
result={out}
|
||||||
{resultNote}
|
{resultNote}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
{textResult}
|
{textResult}
|
||||||
textVars={verdictVars}
|
textVars={verdictVars}
|
||||||
toolId={lastTool.id}
|
toolId={lastTool.id}
|
||||||
{format}
|
running={busy}
|
||||||
quality={currentQuality}
|
mask={masks.masks[i] ?? null}
|
||||||
{limitKb}
|
maskOn={masks.maskOn[i] ?? false}
|
||||||
{alphaLoss}
|
ontogglemask={masks.maskable[i]
|
||||||
{errorText}
|
? () => masks.toggleByIndex(i)
|
||||||
warnings={allWarnings.map(warningText)}
|
: undefined}
|
||||||
{stepResults}
|
|
||||||
{stepFileSets}
|
|
||||||
{stepMasks}
|
|
||||||
{stepMaskOn}
|
|
||||||
{stepMaskable}
|
|
||||||
onaddstep={addStepAt}
|
|
||||||
ontogglestepmask={toggleStepMaskByIndex}
|
|
||||||
ontextinput={(v) => (textSource = v)}
|
|
||||||
onrendertext={run}
|
|
||||||
onupload={handleFile}
|
|
||||||
onuploadmany={handleFiles}
|
|
||||||
ondownload={download}
|
|
||||||
oncopy={copyText}
|
oncopy={copyText}
|
||||||
ondownloadtxt={downloadText}
|
ondownloadtxt={downloadText}
|
||||||
onformat={(v) => (format = v)}
|
|
||||||
onquality={setFormatQuality}
|
|
||||||
onlimit={(v) => (limitKb = v)}
|
|
||||||
/>
|
/>
|
||||||
|
{: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}
|
{/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)}
|
||||||
|
{@render stepCard(step, i)}
|
||||||
|
<div class="pair">
|
||||||
|
{@render inputTile(i)}
|
||||||
|
{@render outputTile(i)}
|
||||||
|
</div>
|
||||||
|
{@render addRow(i + 1)}
|
||||||
|
{/each}
|
||||||
|
<div class="panel-foot" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="workspace">
|
||||||
|
<section class="settings">
|
||||||
|
{@render addRow(0)}
|
||||||
|
{#each steps as step, i (step.key)}
|
||||||
|
{@render stepCard(step, i)}
|
||||||
|
{@render addRow(i + 1)}
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
{@render panelHead()}
|
||||||
|
<div class="pair-grid">
|
||||||
|
<div class="cell">{@render inputTile(0)}</div>
|
||||||
|
{#each steps as step, i (step.key)}
|
||||||
|
<div class="cell">{@render outputTile(i)}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if errorText}
|
||||||
|
<p class="error" role="alert">{errorText}</p>
|
||||||
|
{/if}
|
||||||
|
{#each allWarnings as w (chainWarningText(w))}
|
||||||
|
<p class="warn" role="status">{chainWarningText(w)}</p>
|
||||||
|
{/each}
|
||||||
|
<StepReorderOverlay {steps} dnd={stepDnd} />
|
||||||
|
</DragDropProvider>
|
||||||
</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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user