Compare commits

...
22 Commits
Author SHA1 Message Date
github-actions[bot] 8b94feee79 chore(version): v0.6.0 2026-10-10 06:54:24 +00:00
Ku6epXBOCTuK 6492ef6c61 docs: update backlog, archive arch audit 2026-10-10 11:52:14 +05:00
Ku6epXBOCTuK 2ddb348e54 refactor: better typing for tool icons 2026-10-10 11:47:31 +05:00
Ku6epXBOCTuK 8b2172c39a fix: update field spec types 2026-10-10 10:49:42 +05:00
Ku6epXBOCTuK 4eb657dd50 fix: use toggle component with single pattern 2026-10-10 10:31:03 +05:00
Ku6epXBOCTuK 6288b03ac0 refactor: remove double logic, change names 2026-10-10 10:07:22 +05:00
Ku6epXBOCTuK 637f656229 refactor: parse hex color 2026-10-10 07:07:43 +05:00
Ku6epXBOCTuK 0b929eced2 refactor: move fit to size function 2026-10-10 06:50:07 +05:00
Ku6epXBOCTuK dfb1f531fa refactor: separate output formats from io core 2026-10-10 06:10:10 +05:00
Ku6epXBOCTuK e51d6f3239 refactor: group props in view models 2026-10-10 05:55:05 +05:00
Ku6epXBOCTuK 731f4ba90e refactor: separate registry schema file 2026-10-10 05:35:59 +05:00
Ku6epXBOCTuK 421f078f90 fix: mask result sanitize params 2026-10-10 05:15:23 +05:00
Ku6epXBOCTuK 0b3eeab959 refactor: separate schema tool state 2026-10-10 05:08:39 +05:00
Ku6epXBOCTuK 6d683c1d75 docs: update architecture idiom 2026-10-10 04:31:58 +05:00
Ku6epXBOCTuK 551da338f3 refactor: separate schema tool runner 2026-10-10 04:29:26 +05:00
Ku6epXBOCTuK 52cb9c8a11 refactor: separate SchemaAlignedLayout component 2026-10-10 04:10:13 +05:00
Ku6epXBOCTuK 07c6475c21 refactor: clamp\storage dry 2026-10-09 22:09:02 +05:00
Ku6epXBOCTuK d58f6c90be fix: touched by step flag issue 2026-10-09 21:26:43 +05:00
Ku6epXBOCTuK 9916955b96 refactor: use derived by instead of effect 2026-10-09 21:23:41 +05:00
Ku6epXBOCTuK 6dcbb7c0f8 fix: run gen race condition 2026-10-09 21:17:21 +05:00
Ku6epXBOCTuK 58699abdf1 docs: add arch audit 2026-10-09 21:06:57 +05:00
Ku6epXBOCTuK bac82f8442 feat: flow for multiple files - pipeline, tools 2026-10-09 20:23:14 +05:00
93 changed files with 3116 additions and 1982 deletions
+25
View File
@@ -2,6 +2,31 @@
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.6.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/bac82f8442a8262e757d18f0cc2b5e7fb8291516..v0.6.0) - 2026-10-10
#### Features
- flow for multiple files - pipeline, tools - ([bac82f8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bac82f8442a8262e757d18f0cc2b5e7fb8291516)) - Ku6epXBOCTuK
#### Bug Fixes
- update field spec types - ([8b2172c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8b2172c39aa9cd41118b18d0a7028b4f2752cb8a)) - Ku6epXBOCTuK
- use toggle component with single pattern - ([4eb657d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4eb657dd505aacf2f453b43ab082a599090a6c45)) - Ku6epXBOCTuK
- mask result sanitize params - ([421f078](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/421f078f9051329ca9277425b60ee5b6adfa68b3)) - Ku6epXBOCTuK
- touched by step flag issue - ([d58f6c9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d58f6c90bee245c672f61dbd46e37a8d1df6fca7)) - Ku6epXBOCTuK
- run gen race condition - ([6dcbb7c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6dcbb7c0f8d8876f2ec479dcbce4dff7f9ab104a)) - Ku6epXBOCTuK
#### Refactoring
- better typing for tool icons - ([2ddb348](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2ddb348e54fe681594abe4abe44d3e435277d5c0)) - Ku6epXBOCTuK
- remove double logic, change names - ([6288b03](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6288b03ac0a1c8f63476a4682e43ba96e783955e)) - Ku6epXBOCTuK
- parse hex color - ([637f656](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/637f65622911034c59a1c62b0f5436675765b475)) - Ku6epXBOCTuK
- move fit to size function - ([0b929ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b929eced2980f7509263873f2ceb79cd87dbf52)) - Ku6epXBOCTuK
- separate output formats from io core - ([dfb1f53](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dfb1f531fa121f0750f5b093cec6c63b439df572)) - Ku6epXBOCTuK
- group props in view models - ([e51d6f3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e51d6f32396003c614ba5c8f97943cd37a0883b4)) - Ku6epXBOCTuK
- separate registry schema file - ([731f4ba](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/731f4ba90e3aad3f3a48c9e98abde0b357b3fdd1)) - Ku6epXBOCTuK
- separate schema tool state - ([0b3eeab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b3eeab959de1cf12542b9686b91e9e8c97eb888)) - Ku6epXBOCTuK
- separate schema tool runner - ([551da33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/551da338f38a96b2519e783d1f5d38e17d8d8c9e)) - Ku6epXBOCTuK
- separate SchemaAlignedLayout component - ([52cb9c8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/52cb9c8a1148f50825f9761f5bfbf8ba0776fca2)) - Ku6epXBOCTuK
- clamp\storage dry - ([07c6475](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/07c6475c214868ae035461223fc32c516194aa90)) - Ku6epXBOCTuK
- use derived by instead of effect - ([9916955](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9916955b967a858aff5554c40f65cf9197dda480)) - Ku6epXBOCTuK
- - -
## [v0.5.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/76039f7bf3247ef9711113608f5ec2d01a7fa56f..v0.5.0) - 2026-10-09 ## [v0.5.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/76039f7bf3247ef9711113608f5ec2d01a7fa56f..v0.5.0) - 2026-10-09
#### Features #### Features
- add show mask chip for tools - ([c22c6e5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c22c6e55741f99a6c8a79a82b7b3deadd33fa3dc)) - Ku6epXBOCTuK - add show mask chip for tools - ([c22c6e5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c22c6e55741f99a6c8a79a82b7b3deadd33fa3dc)) - Ku6epXBOCTuK
+48 -14
View File
@@ -12,7 +12,10 @@ web/src/lib/
registry/ # источник истины: инструменты и страницы registry/ # источник истины: инструменты и страницы
registry/tools/ # Tool: реализации (id), без адресов и текстов registry/tools/ # Tool: реализации (id), без адресов и текстов
registry/pages/ # Page: адрес, тексты, категория, список шагов registry/pages/ # Page: адрес, тексты, категория, список шагов
registry-schema.ts # декларативная схема параметров + санитизация дефолтов registry-schema/ # декларативная схема параметров: specs (типы полей),
# field (builder DSL), layout (группы), aspect (aspect-lock
# и кэпы от source), values (дефолты и санитизация);
# index.ts — баррель, импорты идут по `registry-schema`
catalog.ts # группировка страниц и счётчики для /list-tools catalog.ts # группировка страниц и счётчики для /list-tools
categories.ts # id/названия категорий categories.ts # id/названия категорий
tool-icons.ts # slug страницы → иконка tool-icons.ts # slug страницы → иконка
@@ -107,6 +110,13 @@ registry/{tools,pages}/*.ts
`registry-schema.ts`, поэтому добавление вида поля не трогает страницы. `registry-schema.ts`, поэтому добавление вида поля не трогает страницы.
- **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая - **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая
функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод. функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод.
- **Цепочка работает над набором файлов (fan-out).** `run-chain.ts`: «ток» между
шагами — массив именованных изображений (одиночная загрузка = набор из
одного); image-шаг применяется к каждому файлу, files-шаг (split) склеивает
имена частей (`<вход>-<часть>`), analyze-шаг (text/verdict) при batch-входе
видит первый файл. Шаг роняет цепочку, только если упали все файлы
(`ChainStepError`), частичные сбои — warnings. Мультивыбор файлов в drop-зоне;
набор на выходе скачивается zip через `zip.ts`.
## 3. Инструмент и страница ## 3. Инструмент и страница
@@ -163,7 +173,11 @@ registry/{tools,pages}/*.ts
## 4. Схема параметров ## 4. Схема параметров
`registry-schema.ts` — единственное место, где описаны виды полей. `registry-schema/` — единственное место, где описаны виды полей. Модули:
`specs.ts` (типы полей и `ToolSchema`), `field.ts` (фабрика `field.*` и
`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`,
`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (`defaultSchemaParams`,
`applySourceDefaults`, `sanitizeSchemaParams`).
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`, - **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`, `checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
@@ -208,18 +222,18 @@ i18n-подписи → тест в `registry-schema.test.ts`.
## 7. Точки расширения ## 7. Точки расширения
| Задача | Где править | | Задача | Где править |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) | | Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) |
| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен | | Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен |
| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются | | Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются |
| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `tool-icons.ts` + `docs/tools-map.md` + хардкод slug в тестах и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи | | Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `docs/tools-map.md` + хардкод slug в тестах и e2e; иконки править не нужно — `TOOL_ICONS` типизирован `Record<PageSlug, _>` и ломает компиляцию; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи |
| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) | | Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге | | Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` | | Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу | | Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу |
| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов | | Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов |
| Мультифайловый результат | `result: "files"` + `zip.ts` | | Мультифайловый результат | `result: "files"` + `zip.ts` |
## 8. Рецепт: добавить инструмент ## 8. Рецепт: добавить инструмент
@@ -257,3 +271,23 @@ i18n-подписи → тест в `registry-schema.test.ts`.
Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для
инструмента, меняющего пользовательский поток, — e2e (см. раздел 3 инструмента, меняющего пользовательский поток, — e2e (см. раздел 3
`docs/quality-gates.md`). `docs/quality-gates.md`).
## 9. Идиома: вынос логики компонента в `*.svelte.ts`
Крупный компонент-оркестратор (образец — `SchemaToolView.svelte`) держит в себе
только разметку, эффекты и пропсы; исполняемая логика живёт в модуле рядом
(`schema-tool-runner.svelte.ts`). Форма модуля продиктована лимитом
`max-lines-per-function` (100 строк, `web/eslint.config.js`): фабрика
`create*(ctx)` не вмещает все действия замыканиями, поэтому:
- состояние — один `$state`-объект (`RunnerState`), созданный внутри фабрики (не
модульный уровень — состояние на экземпляр компонента);
- действия — модульные функции `action(state, ctx)`; фабрика только создаёт
состояние и возвращает геттеры + привязанные вызовы;
- зависимости от компонента — `ctx` с ленивыми геттерами (`steps: () => steps`),
чтобы не копировать реактивные значения;
- компонент читает состояние через `$derived`-алиасы, пишет — только методами
фабрики.
Побочный плюс: действия тестируются как чистые функции над `(state, ctx)` без
монтирования компонента.
+215
View File
@@ -0,0 +1,215 @@
# Аудит архитектуры (2026-10)
> Статус: archived Архивирован: 2026-10-10 Удалить после: 2026-11-09 Причина:
> все находки разобраны (сделаны или закрыты с обоснованием) Использование:
> только историческая справка
>
> Примечание: `$effect` с `goto()` в `SchemaToolView.svelte:529-546` намеренно
> исключён — бесшовная смена маршрута при структурном изменении цепочки
> (`docs/architecture.md`, раздел 1) является требованием, не дефектом.
Проверено и чисто (не является находкой):
- Слоистость соблюдена: обратных импортов `core→registry/components`,
`registry→components/routes/executor` нет.
- Прямых вызовов `tool.run` в обход `executor.execute` нет.
- DOM-API в `core/**` только в санкционированных `io.ts`/`domText.ts`.
- Устаревшего Svelte 4 синтаксиса нет (`export let`, `$:`, `on:`, `<slot>`,
`$store`, `createEventDispatcher` — 0 совпадений).
- `console.error` в lib/routes отсутствует; worker-протокол кодирует ошибки;
скачивание/encode — единый источник `core/io.ts`.
## High
### H1. `SchemaToolView.svelte` — god-component — СДЕЛАНО (2026-10)
Компонент уменьшен со 1115 до ~660 строк (оркестратор: derived-связки, init,
handleFiles, эффекты, workspace-разметка). Декомпозиция:
- **H1a:** aligned-раскладка вынесена в `SchemaAlignedLayout.svelte` (view-only:
разметка + scoped-стили, сниппет `stepCard` передаётся пропсом).
- **H1b:** runner вынесен в `schema-tool-runner.svelte.ts`
(`createSchemaToolRunner(ctx)`); идиома «`$state`-объект + модульные функции»
зафиксирована в `docs/architecture.md`, раздел 9.
- **H1c:** стейт шагов вынесен в `schema-tool-state.svelte.ts`
(`createSchemaToolState(ctx)`: steps, touchedByStep/lastAxis, все мутации,
DnD; `toolSchemaOf` — модульный экспорт).
### H2. Race condition превью — СДЕЛАНО (2026-10)
`SchemaToolView.svelte`: добавлен generation-counter `runGen`; устаревший прогон
`runChain` после `await` выходит без записи результатов, ошибок и сброса
`running` (`finally` сбрасывает только актуальный); `init()` инкрементом
инвалидирует прогоны прошлого хоста.
### H3. `registry-schema.ts` — 5 ответственностей — СДЕЛАНО (2026-10)
Файл (769 строк) разрезан на директорию `web/src/lib/registry-schema/`:
`specs.ts` (типы полей, `fieldSpecs`, `ToolSchema`), `field.ts` (builder DSL +
`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`,
`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (дефолты и санитизация);
`index.ts` — баррель, пути импортов не менялись. `docs/architecture.md` (разделы
1, 4) обновлён. JSDoc в `specs.ts` сжат до однострочников — лимит плотности
комментариев (`conventions/comment-format`, 15%).
## Med
### M1. Маски через `$effect` вместо `$derived.by` — СДЕЛАНО (2026-10)
`SchemaToolView.svelte`: эффект вычисления `maskResults` заменён на
`$derived.by`; ручной сброс `maskResults = {}` в `init()` удалён — derived
пересчитывается сам по смене `steps`/`source`/`maskOnKeys`.
### M2. `init()` не сбрасывал часть состояния — СДЕЛАНО (2026-10)
`SchemaToolView.svelte`: `init()` теперь очищает `touchedByStep` и `lastAxis` —
метки от ключей шагов прошлого хоста больше не протекают в новую цепочку.
Переработка самого паттерна `$effect(() => init())` — часть H1, отдельно не
делалась.
### M3. `SchemaPreview.svelte` — интерфейс из ~40 пропсов — СДЕЛАНО (2026-10)
Пропсы сгруппированы в 4 view-model объекта по внутреннему потребителю: `head`
(SchemaActions), `source` (SchemaSourceTile), `steps` (промежуточные тайлы),
`result` (SchemaResultTile). Интерфейсы — в `schema-preview-model.ts`
(`PreviewHeadModel` и др.); имена полей зеркалят пропсы потребителей, поэтому
внутри работает спред (`<SchemaActions {...head}>`). Плоскими остались 7
идентифицирующих пропсов (inputMode, resultKind, toolId, running, error,
aligned, ontogglealign).
### M4. `runMask` обходил санитизацию параметров — СДЕЛАНО (2026-10)
`SchemaToolView.svelte` (derived `maskResults`): параметры шага теперь проходят
`sanitizeSchemaParams(stepSchema, step.params, { source: input })` — тот же
контракт, что в `executor/executor.ts` (вход шага как source-контекст).
### M5. UI-метаданные дропдауна скачивания в core-слое — СДЕЛАНО (2026-10)
`OutputFormatOption`/`OUTPUT_FORMATS`/`outputFormatByMime` вынесены из
`core/io.ts` в `web/src/lib/output-formats.ts` (UI-слой: label и
`settings.quality` дропдауна кнопки Download). В core остался только
mime-контракт `OutputMime`; сам `io.ts` таблицу не использовал. Потребители (4
файла) переведены на новый путь.
### M6. Доменная политика сжатия в IO-модуле — СДЕЛАНО (2026-10)
`fitWithinBytes` перенесён из `core/io.ts` в `core/compress.ts` (дом стратегий
сжатия). Сигнатура с инъекцией:
`fitWithinBytes(img, mime, targetBytes, quality, encodeFn)` — вызывающий
(`schema-tool-runner`) передаёт `io.encode`, поэтому `compress.ts` остаётся
DOM-free и node-тестируемым (попутно `fitWithinBytes` теперь можно
юнит-тестировать с фейковым encode). `io.ts` вернулся к чистому IO.
### M7. `clamp` продублирован 5 раз — СДЕЛАНО (2026-10)
Создан `web/src/lib/core/math.ts` (`clamp`, `clampInt`, `clamp01`, `clampByte` —
round-семантика `clampByte` и trunc-семантика `clampInt` сохранены раздельно);
локальные копии удалены из `core/alpha.ts`, `core/background.ts`,
`core/color.ts`, `core/convolution.ts`, `core/geometry.ts`, `core/palette.ts`,
`core/pixel-fx.ts`. Вариант с опциональными границами в `registry-schema.ts`
переведён на общий `clamp` через `?? -Infinity / ?? Infinity`. Копия в
`palette.test.ts` оставлена — тесты независимы.
### M8. Два параллельных hex-парсера — СДЕЛАНО (2026-10)
Единый парсер — `parseHexColor(hex): Rgb` в `core/palette.ts` (терпимый: `#?`,
3/6 цифр, бросает `ToolError`). `hexToRgb` удалён; `parseHex` из `core/alpha.ts`
удалён, потребители (`affine`, `background`, `geometry`, `morphology`, `alpha`)
переведены на объектную деструктуризацию и импорт из `palette`. Тест `parseHex`
переехал из `alpha.test.ts` в `palette.test.ts`. Поведенческое послабление:
бывшие потребители `hexToRgb` теперь принимают и 3-значный hex — вход всё равно
проходит санитизацию схемы.
### M9. Гард `localStorage` продублирован 4 раза — СДЕЛАНО (2026-10)
Создан `web/src/lib/storage.ts` (`storage()` + `safeSetItem`); локальные гарды
удалены из `chains.svelte.ts`, `favorites.svelte.ts`, `i18n/locale.svelte.ts`,
`theme.svelte.ts`.
### M10. Логика темы дублируется между `app.html` и `theme.svelte.ts` — ОТКЛОНЕНО
`web/src/app.html:10-16` повторяет `systemTheme()` (`theme.svelte.ts:11-16`) и
захардкоживает ключ `"theme"` (`theme.svelte.ts:3`).
Решение (2026-10): оставить как есть. Inline-скрипт обязан выполниться синхронно
до парсинга контента, чтобы тема не мигала, — импортировать `theme.svelte.ts` из
него нельзя в принципе (модули async, `app.html` не проходит сборку TS).
Дублирование неизбежно.
Остаточный риск drift'а (принят, без фикса): ключ `"theme"`, значения
`"dark"/"light"` и атрибут `dataset.theme` продублированы; при exception inline
ставит `"light"`, а `initTheme()` при невалидном attr берёт `systemTheme()` (на
практике безразлично — inline всегда выставляет attr). Если риск когда-то
материализуется, дешёвая защита — unit-тест (~20 строк), читающий `app.html` и
пиннющий контракт: ключ `"theme"`, `dataset.theme`, значения `dark`/`light`.
## Low
### L1. Флаг `running` общий для `run()` и `download()` — СДЕЛАНО (2026-10)
В `schema-tool-runner.svelte.ts` флаги разделены: `running` (run) и
`downloading` (download); `download()` больше не затирает индикацию
параллельного auto-run. Компонент передаёт в превью
`busy = running || downloading`.
### L2. Дублирование dismiss-логики поповеров — СДЕЛАНО (2026-10)
Общий хелпер `onDismiss(root, close)` в `web/src/lib/components/ui/dismiss.ts`
(pointerdown-outside + Escape + отписка); `SchemaDownload.svelte` и
`ToolPickerButton.svelte` переведены на него — по ~15 одинаковых строк удалено
из каждого.
### L3. Несогласованный API `Toggle` — СДЕЛАНО (2026-10)
Единый паттерн — `bind:checked` (задокументирован в `Toggle.svelte`; `onchange`
оставлен только для side-эффектов). `SchemaPreview.svelte`: `aligned` стал
`$bindable`, пропс `ontogglealign` удалён; `SchemaToolView` передаёт
`bind:aligned`.
### L4. Непроверяемые `as`-касты схемы — СДЕЛАНО (2026-10)
- 14 field-контролов: `spec: FieldSpec` + `spec as XSpec` → точный тип спеки в
пропсах (`spec: ColorSpec` и т.д.); каст собран в одном месте — типизированный
хелпер `controlFor<S>()` в `SchemaFields.svelte` (ключ kind в `FIELDS`
гарантирует подтип спеки по `fieldSpecs`).
- `SchemaToolView`/`toolSchemaOf`: касты
`as ToolSchema<Record<string, unknown>>` удалены — дефолт дженерика
`Tool<P = Record<string, unknown>>` уже даёт нужный тип; `undefined`
обрабатывается гардами (`{#if stepSchema}` в разметке, `dims && stepSchema` в
`setStepValue`).
- `executor.ts` `as unknown as WorkerLike` оставлен намеренно: несовместимость
`onerror` (ErrorEvent vs Event) в типах lib.dom; добавлен комментарий-причина.
### L5. Хардкод slug-ов вне registry — СДЕЛАНО (2026-10)
`Page` стал readonly, группы `registry/pages/*` —
`as const satisfies readonly Page[]`; в `registry/index.ts` добавлен union
`PageSlug`. `TOOL_ICONS` типизирован `Record<PageSlug, IconComponent>` —
переименование или удаление страницы теперь ломает компиляцию, а не падает
молча. Чтение с внешней строкой — через аксессор `toolIcon(slug)`. Попутно
найдены и удалены 9 мёртвых ключей иконок (страницы уже не существуют:
`png-info`, `jpg-to-png`, `webp-to-png`, `gif-to-png`, `bmp-to-png`,
`ico-to-png`, `compress-png`, `reduce-to-size-png`, `watermark-image-png`).
Добавлен runtime-тест `web/src/lib/tool-icons.test.ts` (оба направления: сироты
и страницы без иконки). Хардкод в `kit/+page.svelte` оставлен — витрина, не
production.
### L6. Дублирование CSS-значения превью-тайла — СДЕЛАНО (2026-10)
Новый токен `--size-tile-canvas-min: clamp(var(--space-brand), 30vh, 60vh)` в
`app.css`; `PreviewTile.svelte` и `SchemaResultTile.svelte` используют
`var(--size-tile-canvas-min)`.
### L7. `localStorage.setItem` без try/catch вне app.html — СДЕЛАНО (2026-10)
Все `setItem` в `theme.svelte.ts`, `chains.svelte.ts`, `favorites.svelte.ts`,
`locale.svelte.ts` переведены на `safeSetItem` из `web/src/lib/storage.ts`
(best-effort persistence: бросок в privacy-режиме проглатывается).
### L8. Переросшие файлы групп реестра и core — НЕ ДЕФЕКТ, закрыто (2026-10)
Размер `registry/tools/*.ts` — от количества инструментов (в `generate.ts` 21
независимая запись `Tool`), конвенция «один файл на категорию» осознанная; SRP
не нарушен, записи независимы. `core/geometry.ts`/`core/palette.ts` —
родственные поддомены в одном модуле, допустимо. Если навигация станет проблемой
— резать на подгруппы по мере роста, отдельной задачи не нужно.
+21 -27
View File
@@ -10,6 +10,12 @@
## Pipeline ## Pipeline
- [ ] **баг: drag-n-drop перестановки шагов не работает** — обработчики на месте
(`StepCard` draggable в сниппете `stepCard` `SchemaToolView.svelte`,
`onStepDrop` → `moveStep` в `schema-tool-state.svelte.ts`), но
перетаскивание не срабатывает. Замечено до рефактора H1 (2026-10), не
регрессия от него. Начать с проверки `draggable`/`dragstart` на `StepCard`
и того, что drop не глушится дочерними элементами карточки
- [ ] названия инструментов — в pipeline инструмент называется «обрезать png», - [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
но он работает с любыми картинками, наверное надо убрать; в заголовке но он работает с любыми картинками, наверное надо убрать; в заголовке
страницы — возможно, надо указать все форматы (связано с SEO) страницы — возможно, надо указать все форматы (связано с SEO)
@@ -18,33 +24,20 @@
- [ ] посмотреть реализацию flip animation svelte для анимации переключения - [ ] посмотреть реализацию flip animation svelte для анимации переключения
режима aligned\normal режима aligned\normal
- [ ] переделать визуал toggle компонента (которым режим переключается) - [ ] переделать визуал toggle компонента (которым режим переключается)
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход — - [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Fan-out и
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного мультивыбор уже есть (идея 15: `web/src/lib/run-chain.ts`, zip-выгрузка
сохранённого chain по каждому файлу, сборка результата обратно в zip для набора). Осталось: zip на вход (распаковка в браузере, порядок файлов,
скачивания. Зависит от идеи 3 (выбор цепочки) и от экспортного формата поведение many → 1 → many), прогресс и отмена пофайлово, запуск по
pipeline JSON; прогресс и ошибки — пофайлово. сохранённой цепочке из `/chains`.
- [ ] **Идея 15. Набор файлов в пайплайне: 1 → many не должен быть - [ ] **Идея 15. Остатки fan-out** — сделано: 1 → many не терминален,
терминальным** — оценка L. **Сейчас** `execute` принимает один мультизагрузка, склейка имён, per-file ошибки с warning. Отложено:
`source: PixelImage | undefined` и возвращает один `ToolResult`, а parts-grid на промежуточных тайлах (сейчас первая картинка набора),
`SchemaToolView` раскладывает вывод в `result` / `fileResult` / прогресс/отмена, analyze-шаги пофайлово (сейчас firstOnly-warning).
`textResult` по `resultKind` — поэтому шаг с `result: "files"` упирается в
конец цепочки. **Направление:** следующий инструмент применяется **ко всем
картинкам** предыдущего результата (fan-out), а many → 1 (вход = архив или
несколько файлов) — обратная сторона того же контракта. Терминальными
остаются только результаты, которые не картинка (text, verdict). Что
обдумать: - где проходит граница: один вызов `run(ctx)` на файл (имя части
склеивается в `FileResult.files`) или батч целиком на стороне исполнителя;
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
прогресс и отмена; - частичные ошибки: один битый файл не должен ронять
весь шаг — нужен per-file отчёт, а не один `ToolError` на шаг; - имена
частей на каждом шаге и Download для набора (сейчас zip по `page.slug`,
`SchemaToolView.download()`); - обратная сторона: распаковка zip в
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
## Инструменты ## Инструменты
- [ ] split-to-parts, загружаем картинку, после этого переключаемся на режим by
tile - интерфейс начинает тормозить. уже пора переходить на worker\wasm?
- [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с - [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с
такими делать вообще? оставить как seo? можно ли под капотом использовать такими делать вообще? оставить как seo? можно ли под капотом использовать
другой инструмент с хардкодед настройками? другой инструмент с хардкодед настройками?
@@ -54,9 +47,6 @@
выделенной области выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор - [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
какой размер - 112\56\28px или 72\36\18) какой размер - 112\56\28px или 72\36\18)
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
файлов, посмотреть реализацию в
[twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать - [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
@@ -134,6 +124,9 @@
## UI и качество ## UI и качество
- [ ] кнопку редактирования названия пайплайна на этой же странице
- [ ] при загрузке нескольких файлов, показывает source не той же высоты, как
result
- [ ] split to parts - посмотреть вариант с toggle component - может будет - [ ] split to parts - посмотреть вариант с toggle component - может будет
удобнее чем sectioned buttons удобнее чем sectioned buttons
- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать - [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать
@@ -202,6 +195,7 @@
## Тесты и lint ## Тесты и lint
- [ ] линтер на deprecated иконки возможен? 'FilePlus2' is deprecated.
- [ ] `tseslint.config(...)` deprecated: сигнатура - [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена `(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "easy-png-tools", "name": "easy-png-tools",
"version": "0.5.0", "version": "0.6.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.5.0", "version": "0.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+1
View File
@@ -151,6 +151,7 @@
--size-preview-canvas-min-height-s: 180px; --size-preview-canvas-min-height-s: 180px;
--size-preview-canvas-min-height-l: 360px; --size-preview-canvas-min-height-l: 360px;
--size-tile-canvas-min: clamp(var(--space-brand), 30vh, 60vh);
--size-panel-min-height: 220px; --size-panel-min-height: 220px;
--size-tool-icon: 42px; --size-tool-icon: 42px;
+4 -5
View File
@@ -2,12 +2,14 @@ import { formatStamp } from "./core/datefmt";
import type { OutputMime } from "./core/io"; import type { OutputMime } from "./core/io";
import type { PixelImage } from "./core/types"; import type { PixelImage } from "./core/types";
import { t } from "./i18n/t"; import { t } from "./i18n/t";
import { safeSetItem, storage } from "./storage";
import { import {
CHAIN_VERSION, CHAIN_VERSION,
parseChain, parseChain,
serializeSteps, serializeSteps,
type ChainStep, type ChainStep,
} from "./pipeline.svelte"; } from "./pipeline.svelte";
import type { ToolImageFile } from "./registry";
export interface NamedChain { export interface NamedChain {
id: string; id: string;
@@ -18,10 +20,6 @@ export interface NamedChain {
const STORE_KEY = "chains:named"; const STORE_KEY = "chains:named";
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function autoChainName(date = new Date()): string { export function autoChainName(date = new Date()): string {
return t("savedChains.autoName", { return t("savedChains.autoName", {
stamp: formatStamp(date, "YYYY-MM-DD hh:mm"), stamp: formatStamp(date, "YYYY-MM-DD hh:mm"),
@@ -91,7 +89,7 @@ function readAll(): NamedChain[] {
} }
function writeAll(chains: NamedChain[]): void { function writeAll(chains: NamedChain[]): void {
storage()?.setItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains))); safeSetItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains)));
} }
/** Newest first. */ /** Newest first. */
@@ -135,6 +133,7 @@ export function deleteChain(id: string): void {
/** State that cannot persist across the tool -> pipeline route swap. */ /** State that cannot persist across the tool -> pipeline route swap. */
export interface ChainHandoff { export interface ChainHandoff {
source: PixelImage | null; source: PixelImage | null;
sourceFiles?: ToolImageFile[];
textSource: string; textSource: string;
format: OutputMime; format: OutputMime;
limitKb: number | undefined; limitKb: number | undefined;
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { CheckboxSpec, FieldSpec } from "$lib/registry-schema"; import type { CheckboxSpec } from "$lib/registry-schema";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: CheckboxSpec;
onchange?: (value: boolean) => void; onchange?: (value: boolean) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as CheckboxSpec); const sp = $derived(spec);
const current = $derived( const current = $derived(
typeof value === "boolean" ? value : Boolean(sp.default), typeof value === "boolean" ? value : Boolean(sp.default),
); );
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { ColorSpec, FieldSpec } from "$lib/registry-schema"; import type { ColorSpec } from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte"; import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: ColorSpec;
onchange?: (value: string) => void; onchange?: (value: string) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorSpec); const sp = $derived(spec);
const current = $derived(typeof value === "string" ? value : sp.default); const current = $derived(typeof value === "string" ? value : sp.default);
</script> </script>
@@ -1,22 +1,18 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { import type { ColorPair, ColorPairSpec } from "$lib/registry-schema";
ColorPair,
ColorPairSpec,
FieldSpec,
} from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte"; import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: ColorPairSpec;
onchange?: (value: ColorPair) => void; onchange?: (value: ColorPair) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorPairSpec); const sp = $derived(spec);
const current = $derived.by(() => { const current = $derived.by(() => {
const v = value as Partial<ColorPair> | undefined; const v = value as Partial<ColorPair> | undefined;
return { return {
@@ -3,22 +3,18 @@
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { import type { ColorList, ColorListSpec } from "$lib/registry-schema";
ColorList,
ColorListSpec,
FieldSpec,
} from "$lib/registry-schema";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: ColorListSpec;
onchange?: (value: ColorList) => void; onchange?: (value: ColorList) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorListSpec); const sp = $derived(spec);
const current = $derived.by((): ColorList => { const current = $derived.by((): ColorList => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const valid = value.filter( const valid = value.filter(
@@ -4,7 +4,6 @@
SIZE_PRESETS, SIZE_PRESETS,
type Dimension, type Dimension,
type DimensionSpec, type DimensionSpec,
type FieldSpec,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import ChipButton from "$lib/components/ui/ChipButton.svelte"; import ChipButton from "$lib/components/ui/ChipButton.svelte";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
@@ -12,7 +11,7 @@
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: DimensionSpec;
/** Step input dimensions: axis cap for maxFromSource fields. */ /** Step input dimensions: axis cap for maxFromSource fields. */
sourceDims?: Dimension; sourceDims?: Dimension;
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void; onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
@@ -25,7 +24,7 @@
onchange, onchange,
}: Props = $props(); }: Props = $props();
const sp = $derived(spec as DimensionSpec); const sp = $derived(spec);
const maxW = $derived( const maxW = $derived(
sp.maxFromSource && sourceDims ? sourceDims.width : sp.max, sp.maxFromSource && sourceDims ? sourceDims.width : sp.max,
); );
@@ -1,23 +1,19 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { TextFont } from "$lib/core/domText"; import type { TextFont } from "$lib/core/domText";
import type { import type { FontStyle, FontStyleSpec } from "$lib/registry-schema";
FieldSpec,
FontStyle,
FontStyleSpec,
} from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte"; import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: FontStyleSpec;
onchange?: (value: FontStyle) => void; onchange?: (value: FontStyle) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as FontStyleSpec); const sp = $derived(spec);
const current = $derived.by(() => { const current = $derived.by(() => {
const v = value as Partial<FontStyle> | undefined; const v = value as Partial<FontStyle> | undefined;
return { return {
@@ -3,17 +3,17 @@
import ColorSwatchInput from "./ColorSwatchInput.svelte"; import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema"; import type { Gradient, GradientSpec } from "$lib/registry-schema";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: GradientSpec;
onchange?: (value: Gradient) => void; onchange?: (value: Gradient) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as GradientSpec); const sp = $derived(spec);
const current = $derived.by(() => { const current = $derived.by(() => {
const v = value as Partial<Gradient> | undefined; const v = value as Partial<Gradient> | undefined;
return { return {
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { FieldSpec, Offset, OffsetSpec } from "$lib/registry-schema"; import type { Offset, OffsetSpec } from "$lib/registry-schema";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: OffsetSpec;
onchange?: (value: Offset) => void; onchange?: (value: Offset) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as OffsetSpec); const sp = $derived(spec);
const current = $derived.by(() => { const current = $derived.by(() => {
const v = value as Partial<Offset> | undefined; const v = value as Partial<Offset> | undefined;
return { return {
@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FieldSpec, Plate, PlateSpec } from "$lib/registry-schema"; import type { Plate, PlateSpec } from "$lib/registry-schema";
import ColorSwatchInput from "./ColorSwatchInput.svelte"; import ColorSwatchInput from "./ColorSwatchInput.svelte";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: PlateSpec;
onchange?: (value: Plate) => void; onchange?: (value: Plate) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as PlateSpec); const sp = $derived(spec);
const current = $derived.by(() => { const current = $derived.by(() => {
const v = value as Partial<Plate> | undefined; const v = value as Partial<Plate> | undefined;
return { return {
@@ -1,21 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { Position9 } from "$lib/core/textdraw"; import type { Position9 } from "$lib/core/textdraw";
import { import { POSITION9_VALUES, type Position9Spec } from "$lib/registry-schema";
POSITION9_VALUES,
type FieldSpec,
type Position9Spec,
} from "$lib/registry-schema";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: Position9Spec;
onchange?: (value: Position9) => void; onchange?: (value: Position9) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as Position9Spec); const sp = $derived(spec);
const current = $derived( const current = $derived(
typeof value === "string" && typeof value === "string" &&
(POSITION9_VALUES as readonly string[]).includes(value) (POSITION9_VALUES as readonly string[]).includes(value)
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { FieldSpec, NumberSpec, SliderSpec } from "$lib/registry-schema"; import type { NumberSpec, SliderSpec } from "$lib/registry-schema";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { Minus, Plus, RotateCcw } from "@lucide/svelte"; import { Minus, Plus, RotateCcw } from "@lucide/svelte";
import IconButton from "$lib/components/ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
@@ -8,12 +8,12 @@
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: NumberSpec | SliderSpec;
onchange?: (value: number) => void; onchange?: (value: number) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as NumberSpec | SliderSpec); const sp = $derived(spec);
const min = $derived(sp.min ?? 0); const min = $derived(sp.min ?? 0);
const max = $derived(sp.max ?? 100); const max = $derived(sp.max ?? 100);
const step = $derived(sp.step ?? 1); const step = $derived(sp.step ?? 1);
@@ -2,19 +2,19 @@
import Control from "./Control.svelte"; import Control from "./Control.svelte";
import Segmented from "$lib/components/ui/Segmented.svelte"; import Segmented from "$lib/components/ui/Segmented.svelte";
import { optionLabel } from "$lib/i18n/schema-tool-strings"; import { optionLabel } from "$lib/i18n/schema-tool-strings";
import type { FieldSpec, SegmentedSpec } from "$lib/registry-schema"; import type { SegmentedSpec } from "$lib/registry-schema";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: SegmentedSpec;
toolId: string; toolId: string;
fieldId: string; fieldId: string;
onchange?: (value: string) => void; onchange?: (value: string) => void;
} }
let { label, value, spec, toolId, fieldId, onchange }: Props = $props(); let { label, value, spec, toolId, fieldId, onchange }: Props = $props();
const sp = $derived(spec as SegmentedSpec); const sp = $derived(spec);
const current = $derived( const current = $derived(
typeof value === "string" && sp.options.some((o) => o.value === value) typeof value === "string" && sp.options.some((o) => o.value === value)
? value ? value
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { optionLabel } from "$lib/i18n/schema-tool-strings"; import { optionLabel } from "$lib/i18n/schema-tool-strings";
import type { FieldSpec, SelectSpec } from "$lib/registry-schema"; import type { SelectSpec } from "$lib/registry-schema";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: SelectSpec;
toolId: string; toolId: string;
fieldId: string; fieldId: string;
onchange?: (value: string) => void; onchange?: (value: string) => void;
} }
let { label, value, spec, toolId, fieldId, onchange }: Props = $props(); let { label, value, spec, toolId, fieldId, onchange }: Props = $props();
const sp = $derived(spec as SelectSpec); const sp = $derived(spec);
const current = $derived( const current = $derived(
typeof value === "string" && sp.options.some((o) => o.value === value) typeof value === "string" && sp.options.some((o) => o.value === value)
? value ? value
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { FieldSpec, TextSpec } from "$lib/registry-schema"; import type { TextSpec } from "$lib/registry-schema";
import Control from "./Control.svelte"; import Control from "./Control.svelte";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: TextSpec;
onchange?: (value: string) => void; onchange?: (value: string) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as TextSpec); const sp = $derived(spec);
const current = $derived(typeof value === "string" ? value : sp.default); const current = $derived(typeof value === "string" ? value : sp.default);
</script> </script>
@@ -5,15 +5,18 @@
interface Props { interface Props {
accept?: string; accept?: string;
onfile?: (file: File) => void; onfile?: (file: File) => void;
onfiles?: (files: File[]) => void;
} }
let { accept = "image/*", onfile }: Props = $props(); let { accept = "image/*", onfile, onfiles }: Props = $props();
let dragging = $state(false); let dragging = $state(false);
let input = $state<HTMLInputElement | null>(null); let input = $state<HTMLInputElement | null>(null);
function handleFiles(files: FileList | null | undefined) { function handleFiles(files: FileList | null | undefined) {
const file = files?.[0]; const list = Array.from(files ?? []);
if (file) onfile?.(file); if (list.length === 0) return;
if (list.length > 1 && onfiles) onfiles(list);
else if (list[0]) onfile?.(list[0]);
} }
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
@@ -50,6 +53,7 @@
bind:this={input} bind:this={input}
type="file" type="file"
{accept} {accept}
multiple
hidden hidden
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)} onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
/> />
@@ -0,0 +1,70 @@
<script lang="ts">
import { toDataUrl } from "$lib/core/io";
import type { ToolImageFile } from "$lib/registry";
interface Props {
files: ToolImageFile[];
}
let { files }: Props = $props();
const parts = $derived(
files.map((file) => ({ name: file.name, url: toDataUrl(file.image) })),
);
</script>
<div class="parts-wrap">
<div class="parts-grid">
{#each parts as part (part.name)}
<figure class="part">
<img src={part.url} alt={part.name} loading="lazy" />
<figcaption>{part.name}</figcaption>
</figure>
{/each}
</div>
</div>
<style>
/* Own scrollbox: usable inside any tile without extra canvas classes. */
.parts-wrap {
width: 100%;
max-height: 60vh;
overflow: auto;
align-self: flex-start;
}
.parts-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(var(--size-parts-grid-min), 1fr)
);
gap: var(--space-m);
width: 100%;
padding: var(--space-l);
box-sizing: border-box;
align-content: start;
}
.part {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.part img {
width: 100%;
height: auto;
display: block;
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 16px 16px;
}
.part figcaption {
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
text-align: center;
overflow-wrap: anywhere;
}
</style>
@@ -93,7 +93,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: clamp(var(--space-brand), 30vh, 60vh); min-height: var(--size-tile-canvas-min);
border: var(--size-border) solid var(--color-border); border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s); border-radius: var(--radius-s);
overflow: hidden; overflow: hidden;
@@ -2,7 +2,7 @@
import DownloadButton from "$lib/components/ui/DownloadButton.svelte"; import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte"; import UploadButton from "$lib/components/ui/UploadButton.svelte";
import type { OutputMime } from "$lib/core/io"; import type { OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/core/io"; import { outputFormatByMime } from "$lib/output-formats";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { ResultKind } from "$lib/registry"; import type { ResultKind } from "$lib/registry";
import SchemaDownload from "./SchemaDownload.svelte"; import SchemaDownload from "./SchemaDownload.svelte";
@@ -0,0 +1,355 @@
<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,11 +1,9 @@
<script lang="ts"> <script lang="ts">
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte"; import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { import { onDismiss } from "$lib/components/ui/dismiss";
OUTPUT_FORMATS, import type { OutputMime } from "$lib/core/io";
outputFormatByMime, import { OUTPUT_FORMATS, outputFormatByMime } from "$lib/output-formats";
type OutputMime,
} from "$lib/core/io";
import type { SliderSpec } from "$lib/registry-schema"; import type { SliderSpec } from "$lib/registry-schema";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { ChevronDown, Download } from "@lucide/svelte"; import { ChevronDown, Download } from "@lucide/svelte";
@@ -61,18 +59,10 @@
$effect(() => { $effect(() => {
if (!open) return; if (!open) return;
function onPointerDown(e: PointerEvent) { return onDismiss(
if (!root?.contains(e.target as Node)) open = false; () => root,
} () => (open = false),
function onKey(e: KeyboardEvent) { );
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
}); });
</script> </script>
@@ -1,8 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { import type {
CheckboxSpec,
ColorListSpec,
ColorPairSpec,
ColorSpec,
Dimension, Dimension,
DimensionSpec,
FieldSpec, FieldSpec,
FieldSpecKind, FieldSpecKind,
FontStyleSpec,
GradientSpec,
NumberSpec,
OffsetSpec,
PlateSpec,
Position9Spec,
SegmentedSpec,
SelectSpec,
SliderSpec,
TextSpec,
ToolSchema, ToolSchema,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import { effectiveMax, resolveLayoutGroups } from "$lib/registry-schema"; import { effectiveMax, resolveLayoutGroups } from "$lib/registry-schema";
@@ -36,22 +51,34 @@
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void; onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
} }
// The kind key of FIELDS guarantees the spec subtype at runtime (fieldSpecs
// map in registry-schema); the cast lives once here, so each control
// declares its exact spec type instead of casting internally.
type ControlFor<S extends FieldSpec> = Component<
Omit<FieldControlProps, "spec"> & { spec: S }
>;
function controlFor<S extends FieldSpec>(
c: ControlFor<S>,
): Component<FieldControlProps> {
return c as unknown as Component<FieldControlProps>;
}
const FIELDS: Record<FieldSpecKind, Component<FieldControlProps>> = { const FIELDS: Record<FieldSpecKind, Component<FieldControlProps>> = {
number: RangeControl, number: controlFor<NumberSpec | SliderSpec>(RangeControl),
slider: RangeControl, slider: controlFor<NumberSpec | SliderSpec>(RangeControl),
color: ColorControl, color: controlFor<ColorSpec>(ColorControl),
"color-pair": ColorPairControl, "color-pair": controlFor<ColorPairSpec>(ColorPairControl),
colors: ColorsControl, colors: controlFor<ColorListSpec>(ColorsControl),
select: SelectControl, select: controlFor<SelectSpec>(SelectControl),
segmented: SegmentedControl, segmented: controlFor<SegmentedSpec>(SegmentedControl),
checkbox: CheckboxControl, checkbox: controlFor<CheckboxSpec>(CheckboxControl),
text: TextControl, text: controlFor<TextSpec>(TextControl),
dimension: DimensionField, dimension: controlFor<DimensionSpec>(DimensionField),
offset: OffsetControl, offset: controlFor<OffsetSpec>(OffsetControl),
position9: PositionControl, position9: controlFor<Position9Spec>(PositionControl),
"font-style": FontStyleControl, "font-style": controlFor<FontStyleSpec>(FontStyleControl),
plate: PlateControl, plate: controlFor<PlateSpec>(PlateControl),
gradient: GradientControl, gradient: controlFor<GradientSpec>(GradientControl),
}; };
interface Props { interface Props {
@@ -1,6 +1,13 @@
<script lang="ts"> <script lang="ts">
import { hasPreviewResult } from "./schema-preview-model"; import {
hasPreviewResult,
type PreviewHeadModel,
type PreviewResultModel,
type PreviewSourceModel,
type PreviewStepsModel,
} from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte"; import SchemaActions from "./SchemaActions.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
@@ -8,92 +15,48 @@
import ChipButton from "$lib/components/ui/ChipButton.svelte"; import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import type { OutputMime } from "$lib/core/io";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { import type { InputMode, ResultKind } from "$lib/registry";
FileResult,
InputMode,
ResultKind,
ResultNote,
} from "$lib/registry";
interface Props { interface Props {
inputMode: InputMode; inputMode: InputMode;
resultKind?: ResultKind; resultKind?: ResultKind;
toolId: string; toolId: string;
source: PixelImage | null;
result: PixelImage | null;
resultNote?: ResultNote | null;
fileResult?: FileResult | null;
textSource?: string;
textResult?: string | null;
textVars?: Record<string, string | number>;
mask?: PixelImage | null;
maskOn?: boolean;
ontogglemask?: () => void;
running?: boolean; running?: boolean;
error?: string; error?: string;
stepResults?: (PixelImage | null)[];
stepMaskable?: boolean[];
stepMaskOn?: boolean[];
stepMasks?: (PixelImage | null)[];
ontogglestepmask?: (i: number) => void;
aligned?: boolean; aligned?: boolean;
ontogglealign?: () => void; head: PreviewHeadModel;
format?: OutputMime; source: PreviewSourceModel;
quality?: number; steps?: PreviewStepsModel;
limitKb?: number; result: PreviewResultModel;
alphaLoss?: boolean;
onupload: (file: File) => void;
ontextsource?: (text: string) => void;
onrendertext?: () => void;
oncopytext?: () => void;
ondownloadtxt?: () => void;
ondownload: () => void;
onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void;
onlimit?: (kb: number | undefined) => void;
} }
let { let {
inputMode, inputMode,
resultKind = "image", resultKind = "image",
toolId, toolId,
source,
result,
resultNote = null,
fileResult = null,
textSource = "",
textResult = null,
textVars = undefined,
mask = null,
maskOn = false,
ontogglemask,
running = false, running = false,
error = "", error = "",
stepResults = [], aligned = $bindable(false),
stepMaskable = [], head,
stepMaskOn = [], source,
stepMasks = [], steps = {},
ontogglestepmask, result,
aligned = false,
ontogglealign,
format = "image/png",
quality = undefined,
limitKb = undefined,
alphaLoss = false,
onupload,
ontextsource,
onrendertext,
oncopytext,
ondownloadtxt,
ondownload,
onformat,
onquality,
onlimit,
}: Props = $props(); }: 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( const hasResult = $derived(
hasPreviewResult({ inputMode, resultKind, result, fileResult, textResult }), hasPreviewResult({
inputMode,
resultKind,
result: result.result,
fileResult: result.fileResult ?? null,
textResult: result.textResult ?? null,
}),
); );
</script> </script>
@@ -101,11 +64,7 @@
<span class="label">{t("resultCard.previewPanel")}</span> <span class="label">{t("resultCard.previewPanel")}</span>
{#if stepResults.length > 1} {#if stepResults.length > 1}
<label class="align-toggle"> <label class="align-toggle">
<Toggle <Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
checked={aligned}
label={t("chain.alignToggle")}
onchange={() => ontogglealign?.()}
/>
<span>{t("chain.alignToggle")}</span> <span>{t("chain.alignToggle")}</span>
</label> </label>
{/if} {/if}
@@ -114,15 +73,7 @@
{resultKind} {resultKind}
canDownload={hasResult} canDownload={hasResult}
{running} {running}
{format} {...head}
{quality}
{limitKb}
{alphaLoss}
{onupload}
{ondownload}
{onformat}
{onquality}
{onlimit}
/> />
</div> </div>
@@ -133,15 +84,31 @@
<div class="pair"> <div class="pair">
<SchemaSourceTile <SchemaSourceTile
mode={inputMode} mode={inputMode}
{source}
{textSource}
{running} {running}
ontextinput={ontextsource} onupload={head.onupload}
{onrendertext} {...source}
{onupload}
/> />
{#each stepResults.slice(0, -1) as img, i (i)} {#each stepResults.slice(0, -1) as img, i (i)}
{#if img} {@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 <PreviewTile
label={t("chain.stepResult", { n: i + 1 })} label={t("chain.stepResult", { n: i + 1 })}
viewMode="image" viewMode="image"
@@ -152,7 +119,7 @@
<ChipButton <ChipButton
label={t("resultCard.maskToggle")} label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false} active={stepMaskOn[i] ?? false}
onclick={() => ontogglestepmask?.(i)} onclick={() => steps.ontogglestepmask?.(i)}
/> />
{/if} {/if}
{/snippet} {/snippet}
@@ -165,21 +132,7 @@
</PreviewTile> </PreviewTile>
{/if} {/if}
{/each} {/each}
<SchemaResultTile <SchemaResultTile {resultKind} {toolId} {running} {...result} />
{resultKind}
{result}
{resultNote}
{fileResult}
{textResult}
{textVars}
{toolId}
{running}
{mask}
{maskOn}
{ontogglemask}
oncopy={oncopytext}
{ondownloadtxt}
/>
</div> </div>
<style> <style>
@@ -5,6 +5,7 @@
import type { FileResult, ResultKind, ResultNote } from "$lib/registry"; import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
import { Check } from "@lucide/svelte"; import { Check } from "@lucide/svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte"; import ChipButton from "$lib/components/ui/ChipButton.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaTextResult from "./SchemaTextResult.svelte"; import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
@@ -46,14 +47,6 @@
? `${result.width} × ${result.height}` ? `${result.width} × ${result.height}`
: undefined, : undefined,
); );
const partUrls = $derived(
resultKind === "files" && fileResult
? fileResult.files.map((file) => ({
name: file.name,
url: toDataUrl(file.image),
}))
: [],
);
let parts = $derived( let parts = $derived(
resultKind === "files" && fileResult ? fileResult.files.length : undefined, resultKind === "files" && fileResult ? fileResult.files.length : undefined,
@@ -85,11 +78,7 @@
/> />
{/if} {/if}
{/snippet} {/snippet}
<div <div class="canvas" class:checker={resultKind === "image"}>
class="canvas"
class:checker={resultKind === "image"}
class:files={resultKind === "files"}
>
{#if resultKind === "text" && textResult} {#if resultKind === "text" && textResult}
<div class="text-result-wrap"> <div class="text-result-wrap">
<SchemaTextResult <SchemaTextResult
@@ -110,15 +99,8 @@
ondownload={ondownloadtxt ?? (() => {})} ondownload={ondownloadtxt ?? (() => {})}
/> />
</div> </div>
{:else if resultKind === "files" && partUrls.length > 0} {:else if resultKind === "files" && fileResult && fileResult.files.length > 0}
<div class="parts-grid"> <PartsGrid files={fileResult.files} />
{#each partUrls as part (part.name)}
<figure class="part">
<img src={part.url} alt={part.name} loading="lazy" />
<figcaption>{part.name}</figcaption>
</figure>
{/each}
</div>
{:else if resultKind === "image" && resultUrl} {:else if resultKind === "image" && resultUrl}
<img <img
data-testid="result-image" data-testid="result-image"
@@ -144,7 +126,7 @@
ломал parts-grid в одну колонку. */ ломал parts-grid в одну колонку. */
width: 100%; width: 100%;
align-self: stretch; align-self: stretch;
min-height: clamp(var(--space-brand), 30vh, 60vh); min-height: var(--size-tile-canvas-min);
border: var(--size-border) solid var(--color-border); border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s); border-radius: var(--radius-s);
overflow: auto; overflow: auto;
@@ -166,51 +148,9 @@
) )
50% / 28px 28px; 50% / 28px 28px;
} }
/* Сетка частей: без потолка 1024 плитки растягивали страницу бесконечно. */
.canvas.files {
max-height: 60vh;
align-items: flex-start;
}
.empty { .empty {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
.parts-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(var(--size-parts-grid-min), 1fr)
);
gap: var(--space-m);
width: 100%;
height: 100%;
padding: var(--space-l);
box-sizing: border-box;
align-content: start;
}
.part {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.part img {
width: 100%;
height: auto;
display: block;
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 16px 16px;
}
.part figcaption {
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
text-align: center;
overflow-wrap: anywhere;
}
.text-result-wrap { .text-result-wrap {
width: 100%; width: 100%;
padding: var(--space-l); padding: var(--space-l);
@@ -2,33 +2,45 @@
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } 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 } from "$lib/registry"; import type { InputMode, ToolImageFile } from "$lib/registry";
import Dropzone from "./Dropzone.svelte"; import Dropzone from "./Dropzone.svelte";
import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
import SchemaTextSource from "./SchemaTextSource.svelte"; import SchemaTextSource from "./SchemaTextSource.svelte";
interface Props { interface Props {
mode: InputMode; mode: InputMode;
source: PixelImage | null; source: PixelImage | null;
sources?: ToolImageFile[];
textSource?: string; textSource?: string;
running?: boolean; running?: boolean;
fileCount?: number;
ontextinput?: (text: string) => void; ontextinput?: (text: string) => void;
onrendertext?: () => void; onrendertext?: () => void;
onupload?: (file: File) => void; onupload?: (file: File) => void;
onuploadmany?: (files: File[]) => void;
} }
let { let {
mode, mode,
source, source,
sources = [],
textSource = "", textSource = "",
running = false, running = false,
fileCount = 0,
ontextinput, ontextinput,
onrendertext, onrendertext,
onupload, onupload,
onuploadmany,
}: Props = $props(); }: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null); const sourceUrl = $derived(source ? toDataUrl(source) : null);
const sourceDims = $derived( const sourceDims = $derived(
source ? `${source.width} × ${source.height}` : undefined, source
? `${source.width} × ${source.height}` +
(fileCount > 1
? ` · ${t("sourceCard.countFiles", { count: fileCount })}`
: "")
: undefined,
); );
let dragging = $state(false); let dragging = $state(false);
@@ -57,8 +69,13 @@
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
e.preventDefault(); e.preventDefault();
dragging = false; dragging = false;
const file = e.dataTransfer?.files?.[0]; pickFiles(Array.from(e.dataTransfer?.files ?? []));
if (file) onupload?.(file); }
function pickFiles(files: File[]) {
if (files.length === 0) return;
if (files.length > 1 && onuploadmany) onuploadmany(files);
else if (files[0]) onupload?.(files[0]);
} }
function open() { function open() {
@@ -106,17 +123,21 @@
bind:this={input} bind:this={input}
type="file" type="file"
accept="image/*" accept="image/*"
multiple
hidden hidden
onchange={(e) => { onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0]; pickFiles(Array.from((e.target as HTMLInputElement).files ?? []));
if (file) onupload?.(file);
}} }}
/> />
<img {#if sources.length > 1}
data-testid="source-image" <PartsGrid files={sources} />
src={sourceUrl} {:else}
alt={t("sourceCard.alt")} <img
/> data-testid="source-image"
src={sourceUrl}
alt={t("sourceCard.alt")}
/>
{/if}
{#if dragging} {#if dragging}
<span class="drop-hint">{t("dropZone.overlayDefault")}</span> <span class="drop-hint">{t("dropZone.overlayDefault")}</span>
{:else} {:else}
@@ -124,7 +145,7 @@
{/if} {/if}
</div> </div>
{:else if mode === "image"} {:else if mode === "image"}
<Dropzone onfile={onupload} /> <Dropzone onfile={onupload} onfiles={onuploadmany} />
{:else} {:else}
<span class="empty">{t("sourceCard.noSource")}</span> <span class="empty">{t("sourceCard.noSource")}</span>
{/if} {/if}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { onDismiss } from "$lib/components/ui/dismiss";
import { pageTitle } from "$lib/i18n/schema-tool-strings"; import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { chainablePages } from "$lib/pipeline.svelte"; import { chainablePages } from "$lib/pipeline.svelte";
import type { Page } from "$lib/registry"; import type { Page } from "$lib/registry";
@@ -24,18 +25,10 @@
$effect(() => { $effect(() => {
if (!open) return; if (!open) return;
function onPointerDown(e: PointerEvent) { return onDismiss(
if (!root?.contains(e.target as Node)) open = false; () => root,
} () => (open = false),
function onKey(e: KeyboardEvent) { );
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
}); });
</script> </script>
@@ -1,5 +1,12 @@
import type { OutputMime } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import type { FileResult, InputMode, ResultKind } from "$lib/registry"; import type {
FileResult,
InputMode,
ResultKind,
ResultNote,
ToolImageFile,
} from "$lib/registry";
export type SchemaPreviewModelInput = { export type SchemaPreviewModelInput = {
inputMode: InputMode; inputMode: InputMode;
@@ -18,3 +25,54 @@ export function hasPreviewResult(input: SchemaPreviewModelInput): boolean {
} }
return Boolean(textResult); return Boolean(textResult);
} }
// Grouped view-models of SchemaPreview: one object per inner consumer
// (field names mirror the consumer's own props, so they spread directly).
/** SchemaActions: download policy and format settings. */
export interface PreviewHeadModel {
format: OutputMime;
quality?: number;
limitKb?: number;
alphaLoss?: boolean;
onupload: (file: File) => void;
ondownload: () => void;
onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void;
onlimit?: (kb: number | undefined) => void;
}
/** SchemaSourceTile minus mode/running/onupload (top-level/head props). */
export interface PreviewSourceModel {
source: PixelImage | null;
sources?: ToolImageFile[];
fileCount?: number;
textSource?: string;
ontextinput?: (text: string) => void;
onrendertext?: () => void;
onuploadmany?: (files: File[]) => void;
}
/** Intermediate step tiles (chain mode). */
export interface PreviewStepsModel {
results?: (PixelImage | null)[];
fileSets?: ToolImageFile[][];
maskable?: boolean[];
maskOn?: boolean[];
masks?: (PixelImage | null)[];
ontogglestepmask?: (i: number) => void;
}
/** SchemaResultTile minus resultKind/toolId/running (top-level props). */
export interface PreviewResultModel {
result: PixelImage | null;
resultNote?: ResultNote | null;
fileResult?: FileResult | null;
textResult?: string | null;
textVars?: Record<string, string | number>;
mask?: PixelImage | null;
maskOn?: boolean;
ontogglemask?: () => void;
oncopy?: () => void;
ondownloadtxt?: () => void;
}
@@ -0,0 +1,270 @@
import { fitWithinBytes } from "$lib/core/compress";
import { ToolError, type ErrorVars } from "$lib/core/errors";
import { downloadBlob, encode, type OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/output-formats";
import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor";
import { chainStepTitle, verdictText } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import type { ChainStep } from "$lib/pipeline.svelte";
import type {
FileResult,
Tool,
ToolImageFile,
ToolResult,
} from "$lib/registry";
import type { Dimension, ToolSchema } from "$lib/registry-schema";
import {
baseName,
ChainStepError,
runChain,
type ChainWarning,
} from "$lib/run-chain";
import { downloadZip } from "$lib/zip";
export type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string };
export interface SchemaToolRunnerCtx {
pageSlug: () => string;
schema: () => ToolSchema<Record<string, unknown>> | undefined;
steps: () => ChainStep[];
source: () => PixelImage | null;
sourceFiles: () => ToolImageFile[];
textSource: () => string;
format: () => OutputMime;
quality: () => number | undefined;
limitKb: () => number | undefined;
lastTool: () => Tool;
}
interface RunnerState {
// run() and download() have separate flags: a parallel auto-run and a
// download click must not clobber each other's indicator/disabled state.
running: boolean;
downloading: boolean;
// Generation of the latest run(): a stale in-flight chain (params changed
// while the worker was busy) must not overwrite newer results.
runGen: number;
result: PixelImage | null;
fileResult: FileResult | null;
textResult: string | null;
verdictVars: Record<string, string | number> | undefined;
stepDims: (Dimension | undefined)[];
stepResults: (PixelImage | null)[];
// Full named set per step (batch/fan-out); intermediate tiles show a grid.
stepFileSets: ToolImageFile[][];
runWarnings: ChainWarning[];
displayError: DisplayError | null;
}
function toDisplayError(e: unknown): DisplayError {
if (e instanceof ToolError) {
return { kind: "i18n", key: e.key, vars: e.vars };
}
if (e instanceof Error) return { kind: "plain", text: e.message };
return { kind: "plain", text: String(e) };
}
function assignResult(s: RunnerState, out: ToolResult) {
if (typeof out === "object" && out !== null && "files" in out) {
s.fileResult = out;
s.result = null;
s.textResult = null;
s.verdictVars = undefined;
} else if (typeof out === "object" && out !== null && "data" in out) {
s.result = out as PixelImage;
s.fileResult = null;
s.textResult = null;
s.verdictVars = undefined;
} else {
if (typeof out === "string") {
s.textResult = out;
s.verdictVars = undefined;
} else if (out && "key" in out) {
s.textResult = out.key;
s.verdictVars = out.vars;
} else {
s.textResult = null;
s.verdictVars = undefined;
}
s.result = null;
s.fileResult = null;
}
}
async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
const schema = ctx.schema();
const steps = ctx.steps();
if (!schema || steps.length === 0) return;
const gen = ++s.runGen;
s.displayError = null;
s.running = true;
try {
const source = ctx.source();
const sourceFiles = ctx.sourceFiles();
const textSource = ctx.textSource();
const input =
sourceFiles.length > 0
? sourceFiles
: source
? [{ name: `${baseName(ctx.pageSlug())}.png`, image: source }]
: [];
const r = await runChain(steps, input, textSource || undefined, execute);
if (gen !== s.runGen) return;
s.stepDims = r.stepDims;
s.stepResults = r.stepResults;
s.stepFileSets = r.stepFileSets;
s.runWarnings = r.warnings;
assignResult(s, r.out);
} catch (e) {
if (gen !== s.runGen) return;
if (e instanceof ChainStepError) {
const base = toDisplayError(e.cause);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
s.displayError = {
kind: "plain",
text: t("toolPage.stepError", {
n: e.stepIndex + 1,
title: chainStepTitle(e.toolId),
msg,
}),
};
} else {
s.displayError = toDisplayError(e);
}
} finally {
if (gen === s.runGen) s.running = false;
}
}
async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
if (!ctx.schema()) return;
s.downloading = true;
s.displayError = null;
try {
if (s.fileResult) {
await downloadZip(s.fileResult.files, `${ctx.pageSlug()}.zip`);
return;
}
if (!s.result) return;
const out = outputFormatByMime(ctx.format());
const q = ctx.quality();
const limit = ctx.limitKb();
const quality = q !== undefined ? q / 100 : undefined;
const blob = limit
? await fitWithinBytes(s.result, out.mime, limit * 1024, quality, encode)
: await encode(s.result, out.mime, quality);
downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`);
} catch (e) {
s.displayError = toDisplayError(e);
} finally {
s.downloading = false;
}
}
async function copyText(s: RunnerState, ctx: SchemaToolRunnerCtx) {
if (!s.textResult) return;
const tool = ctx.lastTool();
const copyValue =
(tool.result ?? "image") === "verdict"
? verdictText(tool.id, s.textResult, s.verdictVars)
: s.textResult;
try {
await navigator.clipboard.writeText(copyValue);
} catch (e) {
s.displayError = toDisplayError(e);
}
}
async function downloadText(s: RunnerState, ctx: SchemaToolRunnerCtx) {
if (!s.textResult) return;
try {
const blob = new Blob([s.textResult], { type: "text/plain" });
downloadBlob(blob, `${ctx.pageSlug()}.txt`);
} catch (e) {
s.displayError = toDisplayError(e);
}
}
function clearResults(s: RunnerState) {
s.result = null;
s.fileResult = null;
s.textResult = null;
s.verdictVars = undefined;
s.displayError = null;
}
// Execution state and actions of SchemaToolView: run/download/clipboard and
// all results. Owns the generation counter (see RunnerState.runGen).
export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
const s = $state<RunnerState>({
running: false,
downloading: false,
runGen: 0,
result: null,
fileResult: null,
textResult: null,
verdictVars: undefined,
stepDims: [],
stepResults: [],
stepFileSets: [],
runWarnings: [],
displayError: null,
});
return {
get running() {
return s.running;
},
get downloading() {
return s.downloading;
},
get result() {
return s.result;
},
get fileResult() {
return s.fileResult;
},
get textResult() {
return s.textResult;
},
get verdictVars() {
return s.verdictVars;
},
get stepDims() {
return s.stepDims;
},
get stepResults() {
return s.stepResults;
},
get stepFileSets() {
return s.stepFileSets;
},
get runWarnings() {
return s.runWarnings;
},
get displayError() {
return s.displayError;
},
set displayError(v: DisplayError | null) {
s.displayError = v;
},
run: () => run(s, ctx),
download: () => download(s, ctx),
copyText: () => copyText(s, ctx),
downloadText: () => downloadText(s, ctx),
// Host changed (another page/chain): drop run state and invalidate any
// in-flight run from the previous host.
resetForHost: () => {
s.runGen++;
s.runWarnings = [];
s.stepFileSets = [];
clearResults(s);
},
clearResults: () => clearResults(s),
reportError: (e: unknown) => {
s.displayError = toDisplayError(e);
},
};
}
@@ -0,0 +1,190 @@
import type { PixelImage } from "$lib/core/types";
import {
createStep,
insertStep,
moveStep,
removeStep,
type ChainStep,
} from "$lib/pipeline.svelte";
import { getTool } from "$lib/registry";
import {
clampSourceAwareMaxes,
defaultSchemaParams,
withAspectLock,
type Dimension,
} from "$lib/registry-schema";
import { SvelteMap, SvelteSet } from "svelte/reactivity";
export interface SchemaToolStateCtx {
source: () => PixelImage | null;
stepDims: () => (Dimension | undefined)[];
clearResults: () => void;
}
interface StepsState {
steps: ChainStep[];
dragFrom: number | null;
}
export function toolSchemaOf(step: ChainStep) {
return getTool(step.id)?.schema;
}
function setStepValue(
s: StepsState,
marks: Marks,
ctx: SchemaToolStateCtx,
index: number,
id: string,
value: unknown,
axis?: "width" | "height" | "both",
) {
const step = s.steps[index];
if (!step) return;
touchedFor(marks, step.key).add(id);
const axisKey = `${step.key}:${id}`;
if (axis) marks.lastAxis[axisKey] = axis === "both" ? "width" : axis;
const next: Record<string, unknown> = { ...step.params, [id]: value };
const stepSchema = toolSchemaOf(step);
const spec = stepSchema?.fields[id]?.spec;
// Aspect derives from the step input: source for the first step,
// previous step's result for the rest (dims known after the run).
const dims =
index === 0 ? (ctx.source() ?? undefined) : ctx.stepDims()[index - 1];
if (dims && stepSchema && spec) {
const aspect = dims.width / dims.height;
if (
spec.kind === "dimension" &&
spec.lockAspectWith &&
axis !== "both" &&
next[spec.lockAspectWith] === true
) {
next[id] = withAspectLock(
value as Dimension,
marks.lastAxis[axisKey] ?? "width",
aspect,
);
} else if (spec.kind === "checkbox" && value === true) {
// lockAspect just enabled: snap bound fields to the aspect at once.
for (const [fid, f] of Object.entries(stepSchema.fields)) {
const fs = f.spec;
if (fs.kind === "dimension" && fs.lockAspectWith === id) {
next[fid] = withAspectLock(
next[fid] as Dimension,
marks.lastAxis[`${step.key}:${fid}`] ?? "width",
aspect,
);
}
}
}
}
const clamped =
dims && stepSchema ? clampSourceAwareMaxes(stepSchema, next, dims) : next;
s.steps = s.steps.with(index, { ...step, params: clamped });
}
function resetStep(
s: StepsState,
marks: Marks,
ctx: SchemaToolStateCtx,
index: number,
) {
const step = s.steps[index];
if (!step) return;
touchedFor(marks, step.key).clear();
const stepSchema = toolSchemaOf(step);
const dims =
index === 0 ? (ctx.source() ?? undefined) : ctx.stepDims()[index - 1];
const params = stepSchema
? defaultSchemaParams(stepSchema, { source: dims })
: {};
s.steps = s.steps.with(index, { ...step, params });
ctx.clearResults();
}
interface Marks {
// Fields edited by the user (per step): on new file load they must not be
// overwritten by source defaults. Step reset clears the mark.
touchedByStep: SvelteMap<string, SvelteSet<string>>;
// Last edited axis of a dimension field; leads under lockAspect.
lastAxis: Record<string, "width" | "height">;
}
function touchedFor(marks: Marks, key: string): SvelteSet<string> {
let set = marks.touchedByStep.get(key);
if (!set) {
set = new SvelteSet();
marks.touchedByStep.set(key, set);
}
return set;
}
// Chain-step state of SchemaToolView: the steps array, per-step edit marks
// and all mutations. Results/execution live in schema-tool-runner.svelte.ts.
export function createSchemaToolState(ctx: SchemaToolStateCtx) {
const s = $state<StepsState>({ steps: [], dragFrom: null });
const marks: Marks = {
touchedByStep: new SvelteMap(),
lastAxis: {},
};
return {
get steps() {
return s.steps;
},
set steps(v: ChainStep[]) {
s.steps = v;
},
get dragFrom() {
return s.dragFrom;
},
set dragFrom(v: number | null) {
s.dragFrom = v;
},
touchedFor: (key: string) => touchedFor(marks, key),
// Marks are keyed by step.key from the previous host; keep them and
// stale keys leak into the new chain's fields.
clearMarks: () => {
marks.touchedByStep.clear();
for (const k of Object.keys(marks.lastAxis)) delete marks.lastAxis[k];
},
setStepValue: (
index: number,
id: string,
value: unknown,
axis?: "width" | "height" | "both",
) => setStepValue(s, marks, ctx, index, id, value, axis),
resetStep: (index: number) => resetStep(s, marks, ctx, index),
addStepAt: (index: number, toolId: string) => {
s.steps = insertStep(s.steps, index, toolId);
},
removeStepAt: (key: string) => {
s.steps = removeStep(s.steps, key);
},
toggleStep: (index: number) => {
const step = s.steps[index];
if (!step) return;
s.steps = s.steps.with(index, {
...step,
collapsed: !step.collapsed,
});
},
// In-place tool swap: step key and collapsed state are kept, params
// become the new tool's defaults, input is recomputed on run.
replaceStepTool: (index: number, toolId: string) => {
const step = s.steps[index];
const next = createStep(toolId);
if (!step || !next) return;
touchedFor(marks, step.key).clear();
s.steps = s.steps.with(index, {
...next,
key: step.key,
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;
},
};
}
+2
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
/** Primary pattern: bind:checked. */
checked: boolean; checked: boolean;
label?: string; label?: string;
/** Only for side effects beyond the value flip. */
onchange?: (checked: boolean) => void; onchange?: (checked: boolean) => void;
} }
let { checked = $bindable(), label, onchange }: Props = $props(); let { checked = $bindable(), label, onchange }: Props = $props();
+19
View File
@@ -0,0 +1,19 @@
// Popover dismiss: close on pointerdown outside the root and on Escape.
// Subscribe inside `$effect` while the popover is open; returns unsubscribe.
export function onDismiss(
root: () => HTMLElement | null,
close: () => void,
): () => void {
function onPointerDown(e: PointerEvent) {
if (!root()?.contains(e.target as Node)) close();
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
}
+8 -8
View File
@@ -1,4 +1,4 @@
import { parseHex } from "./alpha"; import { parseHexColor } from "./palette";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
import { sampleBilinear } from "./geometry"; import { sampleBilinear } from "./geometry";
@@ -28,7 +28,7 @@ export function transformImage(
): PixelImage { ): PixelImage {
const [a, b, c, d, e, f] = dstToSrc; const [a, b, c, d, e, f] = dstToSrc;
const out = createPixelImage(outWidth, outHeight); const out = createPixelImage(outWidth, outHeight);
const bg = bgHex ? parseHex(bgHex) : null; const bg = bgHex ? parseHexColor(bgHex) : null;
for (let y = 0; y < outHeight; y++) { for (let y = 0; y < outHeight; y++) {
for (let x = 0; x < outWidth; x++) { for (let x = 0; x < outWidth; x++) {
const sx = a * x + c * y + e; const sx = a * x + c * y + e;
@@ -41,9 +41,9 @@ export function transformImage(
sy > img.height - 1 + EPS sy > img.height - 1 + EPS
) { ) {
if (bg) { if (bg) {
out.data[di] = bg[0]; out.data[di] = bg.r;
out.data[di + 1] = bg[1]; out.data[di + 1] = bg.g;
out.data[di + 2] = bg[2]; out.data[di + 2] = bg.b;
out.data[di + 3] = 255; out.data[di + 3] = 255;
} }
continue; continue;
@@ -51,9 +51,9 @@ export function transformImage(
const [r, g, bl, al] = sampleBilinear(img, sx, sy); const [r, g, bl, al] = sampleBilinear(img, sx, sy);
if (bg) { if (bg) {
const sa = al / 255; const sa = al / 255;
out.data[di] = r * sa + bg[0] * (1 - sa); out.data[di] = r * sa + bg.r * (1 - sa);
out.data[di + 1] = g * sa + bg[1] * (1 - sa); out.data[di + 1] = g * sa + bg.g * (1 - sa);
out.data[di + 2] = bl * sa + bg[2] * (1 - sa); out.data[di + 2] = bl * sa + bg.b * (1 - sa);
out.data[di + 3] = 255; out.data[di + 3] = 255;
} else { } else {
out.data[di] = r; out.data[di] = r;
-13
View File
@@ -5,7 +5,6 @@ import {
flattenOntoColor, flattenOntoColor,
hardenAlpha, hardenAlpha,
invertAlpha, invertAlpha,
parseHex,
removeColorToAlpha, removeColorToAlpha,
roundCorners, roundCorners,
setAlphaChannel, setAlphaChannel,
@@ -171,15 +170,3 @@ describe("flattenOntoColor", () => {
expect([...out.data]).toEqual([132, 137, 142, 255]); expect([...out.data]).toEqual([132, 137, 142, 255]);
}); });
}); });
describe("parseHex", () => {
it("parses #rrggbb, rrggbb, #rgb", () => {
expect(parseHex("#ff8040")).toEqual([255, 128, 64]);
expect(parseHex("ff8040")).toEqual([255, 128, 64]);
expect(parseHex("#F80")).toEqual([255, 136, 0]);
});
it.each(["zzz", "12345", "##ff", ""])('throws on "%s"', (bad) => {
expect(() => parseHex(bad)).toThrow(/errors\.badHex/);
});
});
+5 -27
View File
@@ -1,5 +1,7 @@
import { createPixelImage, type PixelImage } from "./types"; import { createPixelImage, type PixelImage } from "./types";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { clamp } from "./math";
import { parseHexColor } from "./palette";
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255); const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
@@ -8,7 +10,7 @@ export function removeColorToAlpha(
hex: string, hex: string,
tolerancePercent = 0, tolerancePercent = 0,
): PixelImage { ): PixelImage {
const [targetR, targetG, targetB] = parseHex(hex); const { r: targetR, g: targetG, b: targetB } = parseHexColor(hex);
const tolerance = const tolerance =
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE; (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
const thresholdSq = tolerance * tolerance; const thresholdSq = tolerance * tolerance;
@@ -112,7 +114,7 @@ export function colorMask(
hex: string, hex: string,
tolerancePercent = 0, tolerancePercent = 0,
): PixelImage { ): PixelImage {
const [targetR, targetG, targetB] = parseHex(hex); const { r: targetR, g: targetG, b: targetB } = parseHexColor(hex);
const tolerance = const tolerance =
(clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE; (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
const thresholdSq = tolerance * tolerance; const thresholdSq = tolerance * tolerance;
@@ -131,7 +133,7 @@ export function colorMask(
} }
export function flattenOntoColor(img: PixelImage, hex: string): PixelImage { export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
const [bgR, bgG, bgB] = parseHex(hex); const { r: bgR, g: bgG, b: bgB } = parseHexColor(hex);
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
for (let i = 0; i < out.data.length; i += 4) { for (let i = 0; i < out.data.length; i += 4) {
const a = img.data[i + 3] / 255; const a = img.data[i + 3] / 255;
@@ -143,27 +145,3 @@ export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
} }
return out; return out;
} }
export function parseHex(hex: string): [number, number, number] {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new ToolError("errors.badHex", { value: hex });
}
const digits = match[1];
if (digits.length === 3) {
return [
parseInt(digits[0] + digits[0], 16),
parseInt(digits[1] + digits[1], 16),
parseInt(digits[2] + digits[2], 16),
];
}
return [
parseInt(digits.slice(0, 2), 16),
parseInt(digits.slice(2, 4), 16),
parseInt(digits.slice(4, 6), 16),
];
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+3 -6
View File
@@ -1,5 +1,6 @@
import { parseHex } from "./alpha"; import { parseHexColor } from "./palette";
import { createPixelImage, type PixelImage } from "./types"; import { createPixelImage, type PixelImage } from "./types";
import { clamp } from "./math";
export type BackgroundOptions = { export type BackgroundOptions = {
color: string; color: string;
@@ -92,7 +93,7 @@ export function backgroundRemovalMask(
img: PixelImage, img: PixelImage,
options: BackgroundOptions, options: BackgroundOptions,
): Uint8Array { ): Uint8Array {
const [tr, tg, tb] = parseHex(options.color); const { r: tr, g: tg, b: tb } = parseHexColor(options.color);
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent); const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
if (options.outerOnly) { if (options.outerOnly) {
floodFromBorders(mask, img.width, img.height); floodFromBorders(mask, img.width, img.height);
@@ -132,7 +133,3 @@ export function backgroundMaskPreview(
} }
return out; return out;
} }
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { hexToRgb } from "./palette"; import { parseHexColor } from "./palette";
import { renderSpace, SPACES } from "./channels"; import { renderSpace, SPACES } from "./channels";
import { makeImage } from "./test-helpers"; import { makeImage } from "./test-helpers";
describe("space conversions", () => { describe("space conversions", () => {
it("hsl of red: h=0, s=1, l=0.5", () => { it("hsl of red: h=0, s=1, l=0.5", () => {
const [h, s, l] = SPACES.hsl.convert(hexToRgb("#ff0000")); const [h, s, l] = SPACES.hsl.convert(parseHexColor("#ff0000"));
expect(h).toBeCloseTo(0); expect(h).toBeCloseTo(0);
expect(s).toBeCloseTo(1); expect(s).toBeCloseTo(1);
expect(l).toBeCloseTo(0.5); expect(l).toBeCloseTo(0.5);
+1 -4
View File
@@ -1,5 +1,6 @@
import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { clamp } from "./math";
export type RgbChannel = "red" | "green" | "blue"; export type RgbChannel = "red" | "green" | "blue";
@@ -236,10 +237,6 @@ export function rgbToHex(r: number, g: number, b: number): string {
return `#${byte(r)}${byte(g)}${byte(b)}`; return `#${byte(r)}${byte(g)}${byte(b)}`;
} }
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function gammaCorrection(img: PixelImage, value: number): PixelImage { export function gammaCorrection(img: PixelImage, value: number): PixelImage {
const g = clamp(value, 0.1, 5); const g = clamp(value, 0.1, 5);
const lut = new Uint8ClampedArray(256); const lut = new Uint8ClampedArray(256);
+34
View File
@@ -1,3 +1,7 @@
import type { OutputMime } from "./io";
import { quantizeImage } from "./quantize";
import type { PixelImage } from "./types";
/** Compression presets: level -> number of quantization colors. */ /** Compression presets: level -> number of quantization colors. */
export const COMPRESSION_LEVELS = { export const COMPRESSION_LEVELS = {
light: 192, light: 192,
@@ -80,3 +84,33 @@ export async function findQualityWithin(
} }
return ok; return ok;
} }
export type EncodeFn = (
img: PixelImage,
mime: OutputMime,
quality?: number,
) => Promise<Blob>;
/** Encodes img to fit targetBytes (quality search for lossy, color count for
* PNG; BMP as-is). `encodeFn` injected (core/io.encode): policy stays DOM-free. */
export async function fitWithinBytes(
img: PixelImage,
mime: OutputMime,
targetBytes: number,
quality: number | undefined,
encodeFn: EncodeFn,
): Promise<Blob> {
if (mime === "image/bmp") return encodeFn(img, mime);
const initial = await encodeFn(img, mime, quality);
if (initial.size <= targetBytes) return initial;
if (mime === "image/png") {
const encodeSize = async (k: number) =>
(await encodeFn(quantizeImage(img, k).image, mime)).size;
const k = await findMaxColorsWithin(targetBytes, 256, encodeSize);
return encodeFn(quantizeImage(img, k).image, mime);
}
const encodeSize = async (q: number) =>
(await encodeFn(img, mime, q / 100)).size;
const q = await findQualityWithin(targetBytes, encodeSize);
return encodeFn(img, mime, q / 100);
}
+1 -8
View File
@@ -1,5 +1,6 @@
import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { clamp, clampInt } from "./math";
type Plane = Float64Array; type Plane = Float64Array;
@@ -177,11 +178,3 @@ function boxesForGauss(sigma: number, boxes: number): number[] {
} }
return sizes; return sizes;
} }
function clamp(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v));
}
function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}
+3 -6
View File
@@ -1,6 +1,7 @@
import { parseHex } from "./alpha"; import { parseHexColor } from "./palette";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
import { clampInt } from "./math";
export function expandCanvas( export function expandCanvas(
img: PixelImage, img: PixelImage,
@@ -17,7 +18,7 @@ export function expandCanvas(
const b = Math.max(0, Math.trunc(bottom)); const b = Math.max(0, Math.trunc(bottom));
const out = createPixelImage(img.width + l + r, img.height + t + b); const out = createPixelImage(img.width + l + r, img.height + t + b);
if (backgroundHex !== undefined && backgroundAlpha > 0) { if (backgroundHex !== undefined && backgroundAlpha > 0) {
const [cr, cg, cb] = parseHex(backgroundHex); const { r: cr, g: cg, b: cb } = parseHexColor(backgroundHex);
for (let i = 0; i < out.data.length; i += 4) { for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = cr; out.data[i] = cr;
out.data[i + 1] = cg; out.data[i + 1] = cg;
@@ -285,10 +286,6 @@ export function sampleBilinear(
return result; return result;
} }
function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}
export type Anchor9 = export type Anchor9 =
| "top-left" | "top-left"
| "top-center" | "top-center"
-67
View File
@@ -1,51 +1,10 @@
import { encodeBmpBytes } from "./bmp"; import { encodeBmpBytes } from "./bmp";
import { findMaxColorsWithin, findQualityWithin } from "./compress";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { quantizeImage } from "./quantize";
import { type PixelImage } from "./types"; import { type PixelImage } from "./types";
export type OutputMime = export type OutputMime =
"image/png" | "image/jpeg" | "image/webp" | "image/bmp"; "image/png" | "image/jpeg" | "image/webp" | "image/bmp";
export interface OutputFormatOption {
mime: OutputMime;
ext: string;
label: string;
supportsAlpha: boolean;
/** Fine-grained format settings in the Download button dropdown (extensible). */
settings?: {
quality?: { min: number; max: number; step: number; default: number };
};
}
const QUALITY_SETTING = { min: 1, max: 100, step: 1, default: 90 } as const;
/** Format options in the Download button (selector next to it). */
export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [
{ mime: "image/png", ext: "png", label: "PNG", supportsAlpha: true },
{
mime: "image/jpeg",
ext: "jpg",
label: "JPG",
supportsAlpha: false,
settings: { quality: QUALITY_SETTING },
},
{
mime: "image/webp",
ext: "webp",
label: "WebP",
supportsAlpha: true,
settings: { quality: QUALITY_SETTING },
},
{ mime: "image/bmp", ext: "bmp", label: "BMP", supportsAlpha: false },
];
export function outputFormatByMime(mime: OutputMime): OutputFormatOption {
const found = OUTPUT_FORMATS.find((f) => f.mime === mime);
if (!found) throw new ToolError("errors.encodeUnsupported", { mime });
return found;
}
export const ACCEPTED_IMAGE_TYPES = export const ACCEPTED_IMAGE_TYPES =
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon"; "image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon";
@@ -183,32 +142,6 @@ function canvasToBlob(
}); });
} }
/**
* Encodes img to fit targetBytes: binary search over quality for lossy
* formats, over quantization colors for PNG. BMP has no compression knobs --
* returned as-is (the caller warns the user the limit does not apply).
*/
export async function fitWithinBytes(
img: PixelImage,
mime: OutputMime,
targetBytes: number,
quality?: number,
): Promise<Blob> {
if (mime === "image/bmp") return encode(img, mime);
const initial = await encode(img, mime, quality);
if (initial.size <= targetBytes) return initial;
if (mime === "image/png") {
const encodeSize = async (k: number) =>
(await encode(quantizeImage(img, k).image, mime)).size;
const k = await findMaxColorsWithin(targetBytes, 256, encodeSize);
return encode(quantizeImage(img, k).image, mime);
}
const encodeSize = async (q: number) =>
(await encode(img, mime, q / 100)).size;
const q = await findQualityWithin(targetBytes, encodeSize);
return encode(img, mime, q / 100);
}
export function downloadBlob(blob: Blob, filename: string): void { export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
+3 -3
View File
@@ -1,4 +1,4 @@
import { hexToRgb } from "./palette"; import { parseHexColor } from "./palette";
import type { PixelImage } from "./types"; import type { PixelImage } from "./types";
import { createPixelImage } from "./types"; import { createPixelImage } from "./types";
@@ -22,7 +22,7 @@ export function renderPredicateMask(
): PixelImage { ): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const highlight = o.mode !== "binary"; const highlight = o.mode !== "binary";
const tint = o.color ? hexToRgb(o.color) : { r: 255, g: 0, b: 170 }; const tint = o.color ? parseHexColor(o.color) : { r: 255, g: 0, b: 170 };
const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100; const opacity = Math.min(Math.max(o.opacityPercent ?? 70, 0), 100) / 100;
if (!highlight) { if (!highlight) {
// Binary mask: opaque white-on-black // Binary mask: opaque white-on-black
@@ -95,7 +95,7 @@ export function extractByColor(
tolerancePercent: number, tolerancePercent: number,
): PixelImage { ): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const t = hexToRgb(targetHex); const t = parseHexColor(targetHex);
const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255; const tol = (Math.min(Math.max(tolerancePercent, 0), 100) / 100) * 255;
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
if ( if (
+15
View File
@@ -0,0 +1,15 @@
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}
export function clamp01(value: number): number {
return clamp(value, 0, 1);
}
export function clampByte(value: number): number {
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { parseHex } from "./alpha"; import { parseHexColor } from "./palette";
import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; import { clonePixelImage, createPixelImage, type PixelImage } from "./types";
type Mask = Uint8Array; type Mask = Uint8Array;
@@ -120,7 +120,7 @@ export function strokeImage(
): PixelImage { ): PixelImage {
const r = Math.trunc(radiusPx); const r = Math.trunc(radiusPx);
if (r < 1) return clonePixelImage(img); if (r < 1) return clonePixelImage(img);
const [cr, cg, cb] = parseHex(colorHex); const { r: cr, g: cg, b: cb } = parseHexColor(colorHex);
const mask = buildAlphaMask(img); const mask = buildAlphaMask(img);
const ring = dilateMask(mask, img.width, img.height, r); const ring = dilateMask(mask, img.width, img.height, r);
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
@@ -147,7 +147,7 @@ export function contourImage(
colorHex: string, colorHex: string,
): PixelImage { ): PixelImage {
const r = Math.max(1, Math.trunc(radiusPx)); const r = Math.max(1, Math.trunc(radiusPx));
const [cr, cg, cb] = parseHex(colorHex); const { r: cr, g: cg, b: cb } = parseHexColor(colorHex);
const mask = buildAlphaMask(img); const mask = buildAlphaMask(img);
const inner = erodeMask(mask, img.width, img.height, r); const inner = erodeMask(mask, img.width, img.height, r);
const line = new Uint8Array(mask.length); const line = new Uint8Array(mask.length);
+25 -11
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
analogousSet, analogousSet,
complementarySet, complementarySet,
hexToRgb, parseHexColor,
hslToRgb, hslToRgb,
monochromaticSet, monochromaticSet,
mixColors, mixColors,
@@ -19,9 +19,21 @@ import {
tetradicSet, tetradicSet,
} from "./palette"; } from "./palette";
describe("parseHexColor", () => {
it("parses #rrggbb, rrggbb, #rgb", () => {
expect(parseHexColor("#ff8040")).toEqual({ r: 255, g: 128, b: 64 });
expect(parseHexColor("ff8040")).toEqual({ r: 255, g: 128, b: 64 });
expect(parseHexColor("#F80")).toEqual({ r: 255, g: 136, b: 0 });
});
it.each(["zzz", "12345", "##ff", ""])('throws on "%s"', (bad) => {
expect(() => parseHexColor(bad)).toThrow(/errors\.badHex/);
});
});
describe("rgb<->hsl conversion", () => { describe("rgb<->hsl conversion", () => {
it("red -> hsl(0,100%,50%) and back", () => { it("red -> hsl(0,100%,50%) and back", () => {
const hsl = rgbToHsl(hexToRgb("#ff0000")); const hsl = rgbToHsl(parseHexColor("#ff0000"));
expect(hsl.h).toBeCloseTo(0, 0); expect(hsl.h).toBeCloseTo(0, 0);
expect(hsl.s).toBeCloseTo(1, 5); expect(hsl.s).toBeCloseTo(1, 5);
expect(hsl.l).toBeCloseTo(0.5, 5); expect(hsl.l).toBeCloseTo(0.5, 5);
@@ -29,14 +41,14 @@ describe("rgb<->hsl conversion", () => {
}); });
it("grays have no hue", () => { it("grays have no hue", () => {
expect(rgbToHsl(hexToRgb("#808080")).s).toBe(0); expect(rgbToHsl(parseHexColor("#808080")).s).toBe(0);
}); });
it("360 deg wraparound returns original color", () => { it("360 deg wraparound returns original color", () => {
const base = "#3b82f6"; const base = "#3b82f6";
expect( expect(
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 })), rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(base)), h: 720 + 30 })),
).toBe(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 }))); ).toBe(rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(base)), h: 30 })));
}); });
}); });
@@ -44,15 +56,15 @@ describe("harmonies", () => {
it("complementary - pair shifted 180 deg", () => { it("complementary - pair shifted 180 deg", () => {
const [a, b] = complementarySet("#ff0000"); const [a, b] = complementarySet("#ff0000");
expect(a).toBe("#ff0000"); expect(a).toBe("#ff0000");
const ha = rgbToHsl(hexToRgb(a)).h; const ha = rgbToHsl(parseHexColor(a)).h;
const hb = rgbToHsl(hexToRgb(b)).h; const hb = rgbToHsl(parseHexColor(b)).h;
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0); expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
}); });
it("triadic - three colors 120 deg apart", () => { it("triadic - three colors 120 deg apart", () => {
const set = triadicSet("#ff0000"); const set = triadicSet("#ff0000");
expect(set).toHaveLength(3); expect(set).toHaveLength(3);
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h); const hues = set.map((h) => rgbToHsl(parseHexColor(h)).h);
expect(hues[1] - hues[0]).toBeCloseTo(120, 0); expect(hues[1] - hues[0]).toBeCloseTo(120, 0);
expect(hues[2] - hues[0]).toBeCloseTo(240, 0); expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
}); });
@@ -71,15 +83,17 @@ describe("harmonies", () => {
it("monochromatic keeps hue, varies lightness", () => { it("monochromatic keeps hue, varies lightness", () => {
const set = monochromaticSet("#ff8800", 5, 60); const set = monochromaticSet("#ff8800", 5, 60);
expect(set).toHaveLength(5); expect(set).toHaveLength(5);
const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h))); const hues = new Set(
set.map((h) => Math.round(rgbToHsl(parseHexColor(h)).h)),
);
expect(hues.size).toBe(1); expect(hues.size).toBe(1);
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l); const lights = set.map((h) => rgbToHsl(parseHexColor(h)).l);
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights)); expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
}); });
it("shades - dark edge darker than base", () => { it("shades - dark edge darker than base", () => {
const set = shadeSet("#88cc44", 4, 70); const set = shadeSet("#88cc44", 4, 70);
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l); const lights = set.map((h) => rgbToHsl(parseHexColor(h)).l);
expect(lights[lights.length - 1]).toBeLessThan(lights[0]); expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
}); });
}); });
+23 -20
View File
@@ -1,14 +1,21 @@
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import type { PixelImage } from "./types"; import type { PixelImage } from "./types";
import { createPixelImage } from "./types"; import { createPixelImage } from "./types";
import { clamp01 } from "./math";
export type Rgb = { r: number; g: number; b: number }; export type Rgb = { r: number; g: number; b: number };
export type Hsl = { h: number; s: number; l: number }; export type Hsl = { h: number; s: number; l: number };
export function hexToRgb(hex: string): Rgb { export function parseHexColor(hex: string): Rgb {
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim()); const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!m) throw new ToolError("errors.badHex", { value: hex }); if (!m) throw new ToolError("errors.badHex", { value: hex });
const d = m[1]; const d =
m[1].length === 3
? m[1]
.split("")
.map((ch) => ch + ch)
.join("")
: m[1];
return { return {
r: parseInt(d.slice(0, 2), 16), r: parseInt(d.slice(0, 2), 16),
g: parseInt(d.slice(2, 4), 16), g: parseInt(d.slice(2, 4), 16),
@@ -65,12 +72,12 @@ export function hslToRgb({ h, s, l }: Hsl): Rgb {
} }
export function shiftHue(hex: string, deltaDeg: number): string { export function shiftHue(hex: string, deltaDeg: number): string {
const hsl = rgbToHsl(hexToRgb(hex)); const hsl = rgbToHsl(parseHexColor(hex));
return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg })); return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg }));
} }
function withLightness(hex: string, l: number): string { function withLightness(hex: string, l: number): string {
return rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(hex)), l })); return rgbToHex(hslToRgb({ ...rgbToHsl(parseHexColor(hex)), l }));
} }
export function complementarySet(base: string): string[] { export function complementarySet(base: string): string[] {
@@ -108,7 +115,7 @@ export function monochromaticSet(
rangePercent: number, rangePercent: number,
): string[] { ): string[] {
const n = Math.max(2, Math.min(9, Math.round(count))); const n = Math.max(2, Math.min(9, Math.round(count)));
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l; const baseL = rgbToHsl(parseHexColor(normalizeHex(base))).l;
const halfSpan = Math.min(0.495, rangePercent / 200); const halfSpan = Math.min(0.495, rangePercent / 200);
return Array.from({ length: n }, (_, i) => { return Array.from({ length: n }, (_, i) => {
const t = n === 1 ? 0.5 : i / (n - 1); const t = n === 1 ? 0.5 : i / (n - 1);
@@ -123,7 +130,7 @@ export function shadeSet(
depthPercent: number, depthPercent: number,
): string[] { ): string[] {
const n = Math.max(2, Math.min(9, Math.round(count))); const n = Math.max(2, Math.min(9, Math.round(count)));
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l; const baseL = rgbToHsl(parseHexColor(normalizeHex(base))).l;
const floorL = Math.max(0.03, baseL - depthPercent / 100); const floorL = Math.max(0.03, baseL - depthPercent / 100);
return Array.from({ length: n }, (_, i) => { return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1); const t = i / (n - 1);
@@ -149,7 +156,7 @@ export function normalizeHex(hex: string): string {
export function mixColors(hexes: string[]): string { export function mixColors(hexes: string[]): string {
const sum = hexes const sum = hexes
.map(hexToRgb) .map(parseHexColor)
.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { .reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), {
r: 0, r: 0,
g: 0, g: 0,
@@ -163,7 +170,7 @@ export function mixColors(hexes: string[]): string {
} }
export function luma(hex: string): number { export function luma(hex: string): number {
const { r, g, b } = hexToRgb(hex); const { r, g, b } = parseHexColor(hex);
return 0.2126 * r + 0.7152 * g + 0.0722 * b; return 0.2126 * r + 0.7152 * g + 0.0722 * b;
} }
@@ -172,7 +179,7 @@ export type SortKey = "hue" | "luma" | "sat";
export function sortPalette(hexes: string[], key: SortKey): string[] { export function sortPalette(hexes: string[], key: SortKey): string[] {
const scored = hexes.map((h) => { const scored = hexes.map((h) => {
if (key === "luma") return { h, k: luma(h) }; if (key === "luma") return { h, k: luma(h) };
const hsl = rgbToHsl(hexToRgb(h)); const hsl = rgbToHsl(parseHexColor(h));
return { h, k: key === "hue" ? hsl.h : hsl.s }; return { h, k: key === "hue" ? hsl.h : hsl.s };
}); });
return scored return scored
@@ -180,10 +187,6 @@ export function sortPalette(hexes: string[], key: SortKey): string[] {
.map((s) => s.h); .map((s) => s.h);
} }
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v));
}
/** Horizontal equal swatch columns (strip) or a grid of ~square cells (grid). */ /** Horizontal equal swatch columns (strip) or a grid of ~square cells (grid). */
export function renderSwatches( export function renderSwatches(
colors: string[], colors: string[],
@@ -196,7 +199,7 @@ export function renderSwatches(
const height = Math.max(24, Math.round(cellW)); const height = Math.max(24, Math.round(cellW));
const out = createPixelImage(width, height); const out = createPixelImage(width, height);
colors.forEach((hex, i) => { colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex); const { r, g, b } = parseHexColor(hex);
fillRect( fillRect(
out, out,
Math.floor(i * cellW), Math.floor(i * cellW),
@@ -216,7 +219,7 @@ export function renderSwatches(
const height = cell * rows; const height = cell * rows;
const out = createPixelImage(width, height); const out = createPixelImage(width, height);
colors.forEach((hex, i) => { colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex); const { r, g, b } = parseHexColor(hex);
fillRect( fillRect(
out, out,
(i % cols) * cell, (i % cols) * cell,
@@ -257,8 +260,8 @@ export function renderWheel(size: number, lightness: number): PixelImage {
export function renderBlend(a: string, b: string, width: number): PixelImage { export function renderBlend(a: string, b: string, width: number): PixelImage {
const height = Math.max(24, Math.round(width / 4)); const height = Math.max(24, Math.round(width / 4));
const out = createPixelImage(width, height); const out = createPixelImage(width, height);
const ca = hexToRgb(a); const ca = parseHexColor(a);
const cb = hexToRgb(b); const cb = parseHexColor(b);
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
const t = width === 1 ? 0 : x / (width - 1); const t = width === 1 ? 0 : x / (width - 1);
fillRect( fillRect(
@@ -281,8 +284,8 @@ export function stepColors(
steps: number, steps: number,
): string[] { ): string[] {
const n = Math.max(2, Math.min(12, Math.round(steps))); const n = Math.max(2, Math.min(12, Math.round(steps)));
const ca = hexToRgb(aHex); const ca = parseHexColor(aHex);
const cb = hexToRgb(bHex); const cb = parseHexColor(bHex);
return Array.from({ length: n }, (_, i) => { return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1); const t = i / (n - 1);
return rgbToHex({ return rgbToHex({
+3 -6
View File
@@ -1,7 +1,8 @@
import { hexToRgb } from "./palette"; import { parseHexColor } from "./palette";
import type { PixelImage } from "./types"; import type { PixelImage } from "./types";
import { createPixelImage } from "./types"; import { createPixelImage } from "./types";
import { gaussianBlur } from "./convolution"; import { gaussianBlur } from "./convolution";
import { clampByte } from "./math";
/** Deterministic PRNG (mulberry32): same seed gives the same result. */ /** Deterministic PRNG (mulberry32): same seed gives the same result. */
export function mulberry32(seed: number): () => number { export function mulberry32(seed: number): () => number {
@@ -15,10 +16,6 @@ export function mulberry32(seed: number): () => number {
}; };
} }
function clampByte(v: number): number {
return v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
}
/** Averages each blockSize x blockSize block into a single color. */ /** Averages each blockSize x blockSize block into a single color. */
export function pixelate(img: PixelImage, blockSize: number): PixelImage { export function pixelate(img: PixelImage, blockSize: number): PixelImage {
const bs = Math.max(1, Math.round(blockSize)); const bs = Math.max(1, Math.round(blockSize));
@@ -192,7 +189,7 @@ export function silhouette(
alphaThreshold: number, alphaThreshold: number,
): PixelImage { ): PixelImage {
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
const { r, g, b } = hexToRgb(colorHex); const { r, g, b } = parseHexColor(colorHex);
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
if (img.data[i + 3] <= alphaThreshold) continue; if (img.data[i + 3] <= alphaThreshold) continue;
out.data[i] = r; out.data[i] = r;
+3 -3
View File
@@ -1,7 +1,7 @@
import type { PixelImage } from "./types"; import type { PixelImage } from "./types";
import { createPixelImage } from "./types"; import { createPixelImage } from "./types";
import { rgbToHex } from "./palette"; import { rgbToHex } from "./palette";
import { hexToRgb } from "./palette"; import { parseHexColor } from "./palette";
type Rgb = { r: number; g: number; b: number }; type Rgb = { r: number; g: number; b: number };
@@ -127,7 +127,7 @@ export function mapToNearest(
img: PixelImage, img: PixelImage,
paletteHexes: string[], paletteHexes: string[],
): PixelImage { ): PixelImage {
const palette = paletteHexes.map(hexToRgb); const palette = paletteHexes.map(parseHexColor);
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
for (let i = 0; i < img.data.length; i += 4) { for (let i = 0; i < img.data.length; i += 4) {
out.data[i + 3] = img.data[i + 3]; out.data[i + 3] = img.data[i + 3];
@@ -165,7 +165,7 @@ export function ditherImage(
forcedPaletteHexes?: string[], forcedPaletteHexes?: string[],
): PixelImage { ): PixelImage {
const palette = forcedPaletteHexes const palette = forcedPaletteHexes
? forcedPaletteHexes.map(hexToRgb) ? forcedPaletteHexes.map(parseHexColor)
: medianCutPalette(img, k); : medianCutPalette(img, k);
const total = img.width * img.height; const total = img.width * img.height;
const buf = new Float32Array(total * 3); const buf = new Float32Array(total * 3);
+2
View File
@@ -30,6 +30,8 @@ type PendingRequest = {
}; };
function defaultWorkerFactory(): WorkerLike { function defaultWorkerFactory(): WorkerLike {
// Worker's onerror is typed with ErrorEvent while WorkerLike expects Event;
// the DOM lib types are incompatible, hence the double cast.
return new Worker(new URL("./executor.worker.ts", import.meta.url), { return new Worker(new URL("./executor.worker.ts", import.meta.url), {
type: "module", type: "module",
}) as unknown as WorkerLike; }) as unknown as WorkerLike;
+2 -5
View File
@@ -1,13 +1,10 @@
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from "svelte/reactivity";
import { safeSetItem, storage } from "./storage";
const KEY = "fav-tools"; const KEY = "fav-tools";
const slugs = new SvelteSet<string>(); const slugs = new SvelteSet<string>();
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function initFavorites(): void { export function initFavorites(): void {
const raw = storage()?.getItem(KEY); const raw = storage()?.getItem(KEY);
if (!raw) return; if (!raw) return;
@@ -31,7 +28,7 @@ export function toggleFav(slug: string): void {
} else { } else {
slugs.add(slug); slugs.add(slug);
} }
storage()?.setItem(KEY, JSON.stringify([...slugs])); safeSetItem(KEY, JSON.stringify([...slugs]));
} }
/** Favorite pages from the list (order preserved). */ /** Favorite pages from the list (order preserved). */
+5
View File
@@ -63,6 +63,10 @@ export const en: Dict = {
busyTitle: "Processing…", busyTitle: "Processing…",
busyHint: "Running chain step", busyHint: "Running chain step",
removeStepAria: "Remove step", removeStepAria: "Remove step",
warnPartial: "Step {n}: {ok} of {total} files processed, the rest failed",
warnFirstOnly:
"Step {n}: analyze works on one image — showing the first of {total}",
warnSourceSkip: "{skipped} of {total} files could not be read",
}, },
savedChains: { savedChains: {
pageTitle: "Saved pipelines — easy-png-tools", pageTitle: "Saved pipelines — easy-png-tools",
@@ -85,6 +89,7 @@ export const en: Dict = {
source: "Source", source: "Source",
replaceImage: "Replace image", replaceImage: "Replace image",
replaceHint: "Click to upload an image", replaceHint: "Click to upload an image",
countFiles: "{count} files",
chooseImage: "choose an image", chooseImage: "choose an image",
noSource: "no source — configure the parameters", noSource: "no source — configure the parameters",
alt: "Source image", alt: "Source image",
+2 -5
View File
@@ -1,6 +1,7 @@
import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict"; import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict";
import { ru } from "./ru"; import { ru } from "./ru";
import { en } from "./en"; import { en } from "./en";
import { safeSetItem, storage } from "../storage";
const STORAGE_KEY = "locale"; const STORAGE_KEY = "locale";
@@ -8,10 +9,6 @@ const DICTS: Record<Locale, Dict> = { ru, en };
let locale = $state<Locale>(BASE_LOCALE); let locale = $state<Locale>(BASE_LOCALE);
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function getLocale(): Locale { export function getLocale(): Locale {
return locale; return locale;
} }
@@ -39,7 +36,7 @@ export function getMergedDict(): Dict {
export function setLocale(next: Locale): void { export function setLocale(next: Locale): void {
locale = next; locale = next;
storage()?.setItem(STORAGE_KEY, next); safeSetItem(STORAGE_KEY, next);
syncLangAttr(); syncLangAttr();
} }
+6
View File
@@ -64,6 +64,11 @@ export const ru: Dict = {
busyTitle: "Обработка…", busyTitle: "Обработка…",
busyHint: "Выполняется шаг цепочки", busyHint: "Выполняется шаг цепочки",
removeStepAria: "Убрать шаг", removeStepAria: "Убрать шаг",
warnPartial:
"Шаг {n}: обработано {ok} из {total} файлов, остальные с ошибкой",
warnFirstOnly:
"Шаг {n}: анализ работает с одним изображением — показан первый из {total}",
warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать",
}, },
savedChains: { savedChains: {
pageTitle: "Сохранённые пайплайны — easy-png-tools", pageTitle: "Сохранённые пайплайны — easy-png-tools",
@@ -86,6 +91,7 @@ export const ru: Dict = {
source: "Источник", source: "Источник",
replaceImage: "Заменить изображение", replaceImage: "Заменить изображение",
replaceHint: "Кликните, чтобы загрузить изображение", replaceHint: "Кликните, чтобы загрузить изображение",
countFiles: "{count} шт.",
chooseImage: "выберите изображение", chooseImage: "выберите изображение",
noSource: "нет источника — настройте параметры", noSource: "нет источника — настройте параметры",
alt: "Исходное изображение", alt: "Исходное изображение",
+1 -1
View File
@@ -76,7 +76,7 @@ describe("dictionary completeness for the new registry", () => {
}); });
it("the pages section has no extra pages", () => { it("the pages section has no extra pages", () => {
const slugs = new Set(PAGES.map((page) => page.slug)); const slugs = new Set<string>(PAGES.map((page) => page.slug));
for (const [locale, dict] of [ for (const [locale, dict] of [
["ru", ru], ["ru", ru],
["en", en], ["en", en],
+44
View File
@@ -0,0 +1,44 @@
import { ToolError } from "./core/errors";
import type { OutputMime } from "./core/io";
// Presentation metadata of download formats for the Download button dropdown
// (labels, quality slider ranges). Core keeps only the mime contract
// (core/io.ts); this module is the UI layer's view of the same formats.
export interface OutputFormatOption {
mime: OutputMime;
ext: string;
label: string;
supportsAlpha: boolean;
/** Fine-grained format settings in the Download button dropdown (extensible). */
settings?: {
quality?: { min: number; max: number; step: number; default: number };
};
}
const QUALITY_SETTING = { min: 1, max: 100, step: 1, default: 90 } as const;
/** Format options in the Download button (selector next to it). */
export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [
{ mime: "image/png", ext: "png", label: "PNG", supportsAlpha: true },
{
mime: "image/jpeg",
ext: "jpg",
label: "JPG",
supportsAlpha: false,
settings: { quality: QUALITY_SETTING },
},
{
mime: "image/webp",
ext: "webp",
label: "WebP",
supportsAlpha: true,
settings: { quality: QUALITY_SETTING },
},
{ mime: "image/bmp", ext: "bmp", label: "BMP", supportsAlpha: false },
];
export function outputFormatByMime(mime: OutputMime): OutputFormatOption {
const found = OUTPUT_FORMATS.find((f) => f.mime === mime);
if (!found) throw new ToolError("errors.encodeUnsupported", { mime });
return found;
}
+4 -6
View File
@@ -14,14 +14,12 @@ function uid(): string {
return crypto.randomUUID(); return crypto.randomUUID();
} }
/** Tool can be inserted mid-chain: it accepts and returns an image. */ /** Tool can be inserted mid-chain: image in, one or many images out. */
export function isChainable(toolId: string): boolean { export function isChainable(toolId: string): boolean {
const tool = getTool(toolId); const tool = getTool(toolId);
return ( if (!tool || tool.input !== "image") return false;
tool !== undefined && const kind = tool.result ?? "image";
tool.input === "image" && return kind === "image" || kind === "files";
(tool.result ?? "image") === "image"
);
} }
/** Pages whose tools can be added as steps (for the picker). */ /** Pages whose tools can be added as steps (for the picker). */
+2 -2
View File
@@ -21,10 +21,10 @@ describe("pipeline: step chain", () => {
expect(chain[0].params).toMatchObject({ x: 0, y: 0 }); expect(chain[0].params).toMatchObject({ x: 0, y: 0 });
}); });
it("isChainable: image->image allowed, terminal and text-input not", () => { it("isChainable: image->image and image->files allowed, terminal and text-input not", () => {
expect(isChainable("resize")).toBe(true); expect(isChainable("resize")).toBe(true);
expect(isChainable("split-into-parts")).toBe(true); // result: files, fan-out
expect(isChainable("to-base64")).toBe(false); // result: text expect(isChainable("to-base64")).toBe(false); // result: text
expect(isChainable("split-into-parts")).toBe(false); // result: files
expect(isChainable("from-svg")).toBe(false); // input: text expect(isChainable("from-svg")).toBe(false); // input: text
expect(isChainable("unknown-tool")).toBe(false); expect(isChainable("unknown-tool")).toBe(false);
}); });
-770
View File
@@ -1,770 +0,0 @@
// Typed parameter schema for the UI.
// Compile-time type binding (drift protection): `field.x<T>()` returns
// `Field<T>` whose phantom `__fieldT` ties the runtime spec to the expected
// value type; `toolSchema<P>(fields)` requires `fields` keys to match `P`
// exactly. Compiler errors catch Params/schema mismatches.
import type { TextFont } from "./core/domText";
import type { Position9 } from "./core/textdraw";
/**
* Optional field of every spec: the field's label key in the dictionary.
* Rendered via `fieldLabel` (decision C); without a key, `labelOf(id)` is used.
*/
export interface FieldSpecBase {
label?: string;
/** Field visible only when another field equals a value (tool modes). */
visibleWhen?: { field: string; equals: string };
}
export interface NumberSpec extends FieldSpecBase {
kind: "number";
default: number;
min?: number;
max?: number;
step?: number;
/** Cap from source dimensions (axis); static max is the fallback. */
maxFromSource?: "width" | "height";
maxMinus?: number;
}
export interface SliderSpec extends FieldSpecBase {
kind: "slider";
default: number;
min: number;
max: number;
step?: number;
}
export interface ColorSpec extends FieldSpecBase {
kind: "color";
default: string;
}
export interface SelectSpec<V extends string = string> extends FieldSpecBase {
kind: "select";
default: V;
options: { value: V; label: string }[];
}
/** Same as select but rendered as segment buttons (1-click choice). */
export interface SegmentedSpec<
V extends string = string,
> extends FieldSpecBase {
kind: "segmented";
default: V;
options: { value: V; label: string }[];
}
export interface TextSpec extends FieldSpecBase {
kind: "text";
default: string;
placeholder?: string;
}
export interface CheckboxSpec extends FieldSpecBase {
kind: "checkbox";
default: boolean;
}
/** Composite "dimensions" field: width + height as one object. */
export interface Dimension {
width: number;
height: number;
}
/** Single list of tile presets (squares) for dimension fields. */
export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const;
export interface DimensionSpec extends FieldSpecBase {
kind: "dimension";
/** Shared range for both dimensions. */
min: number;
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
/** Per-axis cap from the matching source dimension. */
maxFromSource?: boolean;
/** Id of the "keep aspect ratio" checkbox field. */
lockAspectWith?: string;
/** Show quick-pick chips from SIZE_PRESETS. */
presets?: boolean;
}
export interface SchemaContext {
source?: Dimension;
}
/** Color pair "from -> to" (gradients, two-color mapping, etc.). */
export interface ColorPair {
from: string;
to: string;
}
export interface ColorPairSpec extends FieldSpecBase {
kind: "color-pair";
from: string;
to: string;
}
/** Color list (palette): array of hex strings. */
export type ColorList = string[];
export interface ColorListSpec extends FieldSpecBase {
kind: "colors";
default: string[];
}
/** Shape/object offset from center: x + y in percent. */
export interface Offset {
x: number;
y: number;
}
export interface OffsetSpec extends FieldSpecBase {
kind: "offset";
/** Shared range for both axes (in percent). */
min: number;
max: number;
x: number;
y: number;
}
/**
* 9-position grid (3x3): top/middle/bottom x left/center/right.
* Values match the `Position9` string type from core/textdraw.
*/
export const POSITION9_VALUES = [
"top-left",
"top-center",
"top-right",
"middle-left",
"center",
"middle-right",
"bottom-left",
"bottom-center",
"bottom-right",
] as const;
export interface Position9Spec extends FieldSpecBase {
kind: "position9";
default: Position9;
}
/**
* Text caption style: font, size, bold, and color as one object
* (reused by text-to-png, add-text, date-stamp).
*/
export interface FontStyle {
font: TextFont;
size: number;
bold: boolean;
color: string;
}
export interface FontStyleSpec extends FieldSpecBase {
kind: "font-style";
/** Font size range. */
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}
/**
* Backing plate under a text caption: on/off, color, and opacity
* as one object (add-text, date-stamp).
*/
export interface Plate {
enabled: boolean;
color: string;
opacity: number;
}
export interface PlateSpec extends FieldSpecBase {
kind: "plate";
enabled: boolean;
color: string;
opacity: number;
}
/**
* Color gradient: color pair + direction angle. 0 deg: left to right,
* 90 deg: top to bottom (clockwise growth in pixel axes, Y axis down).
*/
export interface Gradient {
from: string;
to: string;
angle: number;
}
export interface GradientSpec extends FieldSpecBase {
kind: "gradient";
from: string;
to: string;
/** Gradient direction: 0..360, angle in degrees. */
angle: number;
}
/**
* "as const" object: kind -> field spec. Single source of truth for the kind
* list: `FieldSpecKind` = map keys, `FieldSpec` = union of values. A new kind
* added here makes the type-checker flag where it is missing (default/sanitize).
*/
export const fieldSpecs = {
number: {} as NumberSpec,
slider: {} as SliderSpec,
color: {} as ColorSpec,
select: {} as SelectSpec,
segmented: {} as SegmentedSpec,
text: {} as TextSpec,
checkbox: {} as CheckboxSpec,
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
colors: {} as ColorListSpec,
offset: {} as OffsetSpec,
position9: {} as Position9Spec,
"font-style": {} as FontStyleSpec,
plate: {} as PlateSpec,
gradient: {} as GradientSpec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
export type FieldSpecOf<K extends FieldSpecKind> = (typeof fieldSpecs)[K];
export type FieldSpec = FieldSpecOf<FieldSpecKind>;
/** `Field<T>`: runtime field spec + phantom type of the expected value (number|string|boolean). */
export interface Field<T> {
spec: FieldSpec;
/** Phantom, for type safety only; never set or read at runtime. */
__fieldT?: T;
}
export interface ToolLayoutGroup {
/** Group title (an empty group is not rendered). */
title?: string;
/** Grid column count inside the group (default 1). */
cols?: number;
/** Schema field names belonging to the group. */
fields: string[];
}
export interface ToolSchemaLayout {
/** Widget field groups. Unmentioned fields go to a trailing default group. */
groups: ToolLayoutGroup[];
}
export interface ToolSchemaMeta {
/** Per-tool field layout (grouping/columns). */
layout?: ToolSchemaLayout;
/** Short tool label for the UI (optional). */
label?: string;
}
export interface ToolSchema<P> {
fields: { [K in keyof P]: Field<P[K]> };
layout?: ToolSchemaLayout;
label?: string;
}
export const field = {
number: (s: Omit<NumberSpec, "kind">): Field<number> => ({
spec: { kind: "number", ...s },
}),
slider: (s: Omit<SliderSpec, "kind">): Field<number> => ({
spec: { kind: "slider", ...s },
}),
color: (s: Omit<ColorSpec, "kind">): Field<string> => ({
spec: { kind: "color", ...s },
}),
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
spec: { kind: "select", ...s },
}),
segmented: <V extends string>(
s: Omit<SegmentedSpec<V>, "kind">,
): Field<V> => ({
spec: { kind: "segmented", ...s },
}),
text: (s: Omit<TextSpec, "kind">): Field<string> => ({
spec: { kind: "text", ...s },
}),
checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({
spec: { kind: "checkbox", ...s },
}),
dimension: (s: {
label?: string;
min: number;
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
maxFromSource?: boolean;
lockAspectWith?: string;
presets?: boolean;
visibleWhen?: { field: string; equals: string };
}): Field<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
colorPair: (s: {
label?: string;
from: string;
to: string;
}): Field<ColorPair> => ({
spec: { kind: "color-pair", ...s },
}),
colors: (s: { label?: string; default: string[] }): Field<ColorList> => ({
spec: { kind: "colors", ...s },
}),
offset: (s: {
label?: string;
min: number;
max: number;
x: number;
y: number;
}): Field<Offset> => ({
spec: { kind: "offset", ...s },
}),
position9: (s: { label?: string; default: Position9 }): Field<Position9> => ({
spec: { kind: "position9", ...s },
}),
fontStyle: (s: {
label?: string;
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}): Field<FontStyle> => ({
spec: { kind: "font-style", ...s },
}),
plate: (s: {
label?: string;
enabled: boolean;
color: string;
opacity: number;
}): Field<Plate> => ({
spec: { kind: "plate", ...s },
}),
gradient: (s: {
label?: string;
from: string;
to: string;
angle: number;
}): Field<Gradient> => ({
spec: { kind: "gradient", ...s },
}),
};
/**
* Builds a `ToolSchema<P>` from declared fields.
* The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`.
*/
export function toolSchema<P>(
fields: { [K in keyof P]: Field<P[K]> },
meta?: ToolSchemaMeta,
): ToolSchema<P> {
return { fields, ...(meta ?? {}) };
}
export interface ResolvedLayoutGroup {
key: string;
title?: string;
cols: number;
fields: string[];
}
export function resolveLayoutGroups<P>(
schema: ToolSchema<P>,
): ResolvedLayoutGroup[] {
const all = Object.keys(schema.fields);
const groups = schema.layout?.groups ?? [];
const used: Record<string, true> = {};
const named: ResolvedLayoutGroup[] = groups
.map((group, index) => {
const fields = group.fields.filter((fieldId) => {
if (used[fieldId] || !(fieldId in schema.fields)) return false;
used[fieldId] = true;
return true;
});
return {
key: `${group.title ?? "group"}-${index}`,
title: group.title,
cols: Math.max(1, Math.trunc(group.cols ?? 1)),
fields,
} satisfies ResolvedLayoutGroup;
})
.filter((group) => group.fields.length > 0);
const rest = all.filter((fieldId) => !used[fieldId]);
if (rest.length > 0) {
named.push({ key: "__default", cols: 1, fields: rest });
}
return named;
}
function clamp(value: number, min?: number, max?: number): number {
if (min !== undefined && value < min) return min;
if (max !== undefined && value > max) return max;
return value;
}
/** Field cap: from source dimensions under maxFromSource, else static. */
export function effectiveMax(
spec: NumberSpec,
source?: Dimension,
): number | undefined {
if (spec.maxFromSource && source) {
return source[spec.maxFromSource] - (spec.maxMinus ?? 0);
}
return spec.max;
}
function dimensionAxisMax(
spec: DimensionSpec,
axis: "width" | "height",
source?: Dimension,
): number {
return spec.maxFromSource && source ? source[axis] : spec.max;
}
/** Clamps maxFromSource fields to the current cap (source changed). */
export function clampSourceAwareMaxes<P>(
schema: ToolSchema<P>,
params: Record<string, unknown>,
source?: Dimension,
): Record<string, unknown> {
if (!source) return params;
let changed = false;
const out = { ...params };
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "number" && spec.maxFromSource) {
const v = out[key as string];
if (typeof v !== "number" || !Number.isFinite(v)) continue;
const clamped = clamp(v, spec.min, effectiveMax(spec, source));
if (clamped !== v) {
out[key as string] = clamped;
changed = true;
}
} else if (spec.kind === "dimension" && spec.maxFromSource) {
const v = out[key as string] as Partial<Dimension> | undefined;
if (typeof v?.width !== "number" || typeof v?.height !== "number") {
continue;
}
const width = clamp(
v.width,
spec.min,
dimensionAxisMax(spec, "width", source),
);
const height = clamp(
v.height,
spec.min,
dimensionAxisMax(spec, "height", source),
);
if (width !== v.width || height !== v.height) {
out[key as string] = { width, height };
changed = true;
}
}
}
return changed ? out : params;
}
/**
* Recompute under lockAspect: the leading axis (last user edit) is kept,
* the other is fitted to the source aspect.
*/
export function withAspectLock(
next: Dimension,
leading: "width" | "height",
aspect: number,
): Dimension {
if (leading === "width") {
return { ...next, height: Math.max(1, Math.round(next.width / aspect)) };
}
return { ...next, width: Math.max(1, Math.round(next.height * aspect)) };
}
function resolveDimensionDefaults(
spec: DimensionSpec,
context?: SchemaContext,
): Dimension {
const source = spec.defaultFromSource ? context?.source : undefined;
return {
width: clamp(source?.width ?? spec.width, spec.min, spec.max),
height: clamp(source?.height ?? spec.height, spec.min, spec.max),
};
}
/** Defaults from the schema: the single source of default values for the UI. */
export function defaultSchemaParams<P>(
schema: ToolSchema<P>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "dimension") {
out[key] = resolveDimensionDefaults(spec, context);
} else if (spec.kind === "color-pair") {
out[key] = { from: spec.from, to: spec.to };
} else if (spec.kind === "colors") {
out[key] = [...spec.default];
} else if (spec.kind === "offset") {
out[key] = { x: spec.x, y: spec.y };
} else if (spec.kind === "font-style") {
out[key] = {
font: spec.font,
size: spec.size,
bold: spec.bold,
color: spec.color,
};
} else if (spec.kind === "plate") {
out[key] = {
enabled: spec.enabled,
color: spec.color,
opacity: spec.opacity,
};
} else if (spec.kind === "gradient") {
out[key] = {
from: spec.from,
to: spec.to,
angle: spec.angle,
};
} else {
out[key] = spec.default;
}
}
return out;
}
/**
* Recomputes defaults from a new source, preserving user values. Fields in
* touched (edited manually) are not overwritten even with a source default;
* otherwise loading a new file would wipe configured sizes.
*/
export function applySourceDefaults<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context: SchemaContext,
touched?: ReadonlySet<string>,
): Record<keyof P, unknown> {
const defaults = defaultSchemaParams(schema, context);
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
const fromSource = spec.kind === "dimension" && spec.defaultFromSource;
if (fromSource && !touched?.has(key as string)) continue;
if (Object.prototype.hasOwnProperty.call(values, key)) {
defaults[key] = values[key as string];
}
}
return defaults;
}
/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */
export function sanitizeSchemaParams<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
const raw = values[key as string];
switch (spec.kind) {
case "number":
case "slider": {
const n =
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
const max =
spec.kind === "number"
? effectiveMax(spec, context?.source)
: spec.max;
out[key] = clamp(n, spec.min, max);
break;
}
case "select":
case "segmented":
out[key] =
typeof raw === "string" && spec.options.some((o) => o.value === raw)
? raw
: spec.default;
break;
case "checkbox":
out[key] = typeof raw === "boolean" ? raw : spec.default;
break;
case "color":
out[key] =
typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw)
? raw
: spec.default;
break;
case "text":
out[key] = typeof raw === "string" ? raw : spec.default;
break;
case "dimension": {
const r =
typeof raw === "object" &&
raw !== null &&
"width" in raw &&
"height" in raw
? (raw as Record<string, unknown>)
: undefined;
const defaults = resolveDimensionDefaults(spec, context);
const w =
typeof r?.width === "number" && Number.isFinite(r.width)
? r.width
: defaults.width;
const h =
typeof r?.height === "number" && Number.isFinite(r.height)
? r.height
: defaults.height;
const sourceSize =
spec.defaultFromSource && context?.source && w === 0 && h === 0
? defaults
: { width: w, height: h };
out[key] = {
width: clamp(
sourceSize.width,
spec.min,
dimensionAxisMax(spec, "width", context?.source),
),
height: clamp(
sourceSize.height,
spec.min,
dimensionAxisMax(spec, "height", context?.source),
),
};
break;
}
case "color-pair": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw
? (raw as Record<string, unknown>)
: undefined;
const from =
typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from)
? r.from
: spec.from;
const to =
typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to)
? r.to
: spec.to;
out[key] = { from, to };
break;
}
case "colors": {
const r =
Array.isArray(raw) &&
raw.every((c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c))
? (raw as string[])
: undefined;
out[key] = r && r.length > 0 ? [...r] : [...spec.default];
break;
}
case "position9":
out[key] =
typeof raw === "string" &&
(POSITION9_VALUES as readonly string[]).includes(raw)
? (raw as Position9)
: spec.default;
break;
case "font-style": {
const r =
typeof raw === "object" &&
raw !== null &&
"font" in raw &&
"size" in raw &&
"bold" in raw &&
"color" in raw
? (raw as Record<string, unknown>)
: undefined;
out[key] = {
font:
r?.font === "sans" || r?.font === "serif" || r?.font === "mono"
? r.font
: spec.font,
size:
typeof r?.size === "number" && Number.isFinite(r.size)
? clamp(r.size, spec.min, spec.max)
: spec.size,
bold: typeof r?.bold === "boolean" ? r.bold : spec.bold,
color:
typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color)
? r.color
: spec.color,
};
break;
}
case "plate": {
const r =
typeof raw === "object" &&
raw !== null &&
"enabled" in raw &&
"color" in raw &&
"opacity" in raw
? (raw as Record<string, unknown>)
: undefined;
out[key] = {
enabled: typeof r?.enabled === "boolean" ? r.enabled : spec.enabled,
color:
typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color)
? r.color
: spec.color,
opacity:
typeof r?.opacity === "number" && Number.isFinite(r.opacity)
? clamp(r.opacity, 0, 100)
: spec.opacity,
};
break;
}
case "offset": {
const r =
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
? (raw as Record<string, unknown>)
: undefined;
const x =
typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x;
const y =
typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y;
out[key] = {
x: clamp(x, spec.min, spec.max),
y: clamp(y, spec.min, spec.max),
};
break;
}
case "gradient": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw &&
"angle" in raw
? (raw as Record<string, unknown>)
: undefined;
out[key] = {
from:
typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from)
? r.from
: spec.from,
to:
typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to)
? r.to
: spec.to,
angle:
typeof r?.angle === "number" && Number.isFinite(r.angle)
? clamp(r.angle, 0, 360)
: spec.angle,
};
break;
}
}
}
return out;
}
+83
View File
@@ -0,0 +1,83 @@
import { clamp } from "../core/math";
import type { Dimension, DimensionSpec, NumberSpec, ToolSchema } from "./specs";
/** Field cap: from source dimensions under maxFromSource, else static. */
export function effectiveMax(
spec: NumberSpec,
source?: Dimension,
): number | undefined {
if (spec.maxFromSource && source) {
return source[spec.maxFromSource] - (spec.maxMinus ?? 0);
}
return spec.max;
}
export function dimensionAxisMax(
spec: DimensionSpec,
axis: "width" | "height",
source?: Dimension,
): number {
return spec.maxFromSource && source ? source[axis] : spec.max;
}
/** Clamps maxFromSource fields to the current cap (source changed). */
export function clampSourceAwareMaxes<P>(
schema: ToolSchema<P>,
params: Record<string, unknown>,
source?: Dimension,
): Record<string, unknown> {
if (!source) return params;
let changed = false;
const out = { ...params };
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "number" && spec.maxFromSource) {
const v = out[key as string];
if (typeof v !== "number" || !Number.isFinite(v)) continue;
const clamped = clamp(
v,
spec.min ?? -Infinity,
effectiveMax(spec, source) ?? Infinity,
);
if (clamped !== v) {
out[key as string] = clamped;
changed = true;
}
} else if (spec.kind === "dimension" && spec.maxFromSource) {
const v = out[key as string] as Partial<Dimension> | undefined;
if (typeof v?.width !== "number" || typeof v?.height !== "number") {
continue;
}
const width = clamp(
v.width,
spec.min ?? -Infinity,
dimensionAxisMax(spec, "width", source),
);
const height = clamp(
v.height,
spec.min ?? -Infinity,
dimensionAxisMax(spec, "height", source),
);
if (width !== v.width || height !== v.height) {
out[key as string] = { width, height };
changed = true;
}
}
}
return changed ? out : params;
}
/**
* Recompute under lockAspect: the leading axis (last user edit) is kept,
* the other is fitted to the source aspect.
*/
export function withAspectLock(
next: Dimension,
leading: "width" | "height",
aspect: number,
): Dimension {
if (leading === "width") {
return { ...next, height: Math.max(1, Math.round(next.width / aspect)) };
}
return { ...next, width: Math.max(1, Math.round(next.height * aspect)) };
}
+121
View File
@@ -0,0 +1,121 @@
import type { TextFont } from "../core/domText";
import type { Position9 } from "../core/textdraw";
import type {
CheckboxSpec,
ColorList,
ColorPair,
ColorSpec,
Dimension,
Field,
FontStyle,
Gradient,
NumberSpec,
Offset,
Plate,
SegmentedSpec,
SelectSpec,
SliderSpec,
TextSpec,
ToolSchema,
ToolSchemaMeta,
} from "./specs";
export const field = {
number: (s: Omit<NumberSpec, "kind">): Field<number> => ({
spec: { kind: "number", ...s },
}),
slider: (s: Omit<SliderSpec, "kind">): Field<number> => ({
spec: { kind: "slider", ...s },
}),
color: (s: Omit<ColorSpec, "kind">): Field<string> => ({
spec: { kind: "color", ...s },
}),
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
spec: { kind: "select", ...s },
}),
segmented: <V extends string>(
s: Omit<SegmentedSpec<V>, "kind">,
): Field<V> => ({
spec: { kind: "segmented", ...s },
}),
text: (s: Omit<TextSpec, "kind">): Field<string> => ({
spec: { kind: "text", ...s },
}),
checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({
spec: { kind: "checkbox", ...s },
}),
dimension: (s: {
label?: string;
min: number;
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
maxFromSource?: boolean;
lockAspectWith?: string;
presets?: boolean;
visibleWhen?: { field: string; equals: string };
}): Field<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
colorPair: (s: {
label?: string;
from: string;
to: string;
}): Field<ColorPair> => ({
spec: { kind: "color-pair", ...s },
}),
colors: (s: { label?: string; default: string[] }): Field<ColorList> => ({
spec: { kind: "colors", ...s },
}),
offset: (s: {
label?: string;
min: number;
max: number;
x: number;
y: number;
}): Field<Offset> => ({
spec: { kind: "offset", ...s },
}),
position9: (s: { label?: string; default: Position9 }): Field<Position9> => ({
spec: { kind: "position9", ...s },
}),
fontStyle: (s: {
label?: string;
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}): Field<FontStyle> => ({
spec: { kind: "font-style", ...s },
}),
plate: (s: {
label?: string;
enabled: boolean;
color: string;
opacity: number;
}): Field<Plate> => ({
spec: { kind: "plate", ...s },
}),
gradient: (s: {
label?: string;
from: string;
to: string;
angle: number;
}): Field<Gradient> => ({
spec: { kind: "gradient", ...s },
}),
};
/**
* Builds a `ToolSchema<P>` from declared fields.
* The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`.
*/
export function toolSchema<P>(
fields: { [K in keyof P]: Field<P[K]> },
meta?: ToolSchemaMeta,
): ToolSchema<P> {
return { fields, ...(meta ?? {}) };
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./aspect";
export * from "./field";
export * from "./layout";
export * from "./specs";
export * from "./values";
+36
View File
@@ -0,0 +1,36 @@
import type { ToolSchema } from "./specs";
export interface ResolvedLayoutGroup {
key: string;
title?: string;
cols: number;
fields: string[];
}
export function resolveLayoutGroups<P>(
schema: ToolSchema<P>,
): ResolvedLayoutGroup[] {
const all = Object.keys(schema.fields);
const groups = schema.layout?.groups ?? [];
const used: Record<string, true> = {};
const named: ResolvedLayoutGroup[] = groups
.map((group, index) => {
const fields = group.fields.filter((fieldId) => {
if (used[fieldId] || !(fieldId in schema.fields)) return false;
used[fieldId] = true;
return true;
});
return {
key: `${group.title ?? "group"}-${index}`,
title: group.title,
cols: Math.max(1, Math.trunc(group.cols ?? 1)),
fields,
} satisfies ResolvedLayoutGroup;
})
.filter((group) => group.fields.length > 0);
const rest = all.filter((fieldId) => !used[fieldId]);
if (rest.length > 0) {
named.push({ key: "__default", cols: 1, fields: rest });
}
return named;
}
+250
View File
@@ -0,0 +1,250 @@
// Typed parameter schema for the UI. Compile-time type binding (drift
// protection): the phantom `Field.__fieldT` ties the runtime spec to the
// expected value type, so the compiler catches Params/schema mismatches.
import type { TextFont } from "../core/domText";
import type { Position9 } from "../core/textdraw";
/** Optional field of every spec: label key in the dictionary (see `fieldLabel`). */
export interface FieldSpecBase {
label?: string;
/** Field visible only when another field equals a value (tool modes). */
visibleWhen?: { field: string; equals: string };
}
export interface NumberSpec extends FieldSpecBase {
kind: "number";
default: number;
min?: number;
max?: number;
step?: number;
/** Cap from source dimensions (axis); static max is the fallback. */
maxFromSource?: "width" | "height";
maxMinus?: number;
}
export interface SliderSpec extends FieldSpecBase {
kind: "slider";
default: number;
min: number;
max: number;
step?: number;
}
export interface ColorSpec extends FieldSpecBase {
kind: "color";
default: string;
}
export interface SelectSpec<V extends string = string> extends FieldSpecBase {
kind: "select";
default: V;
options: { value: V; label: string }[];
}
/** Same as select but rendered as segment buttons (1-click choice). */
export interface SegmentedSpec<
V extends string = string,
> extends FieldSpecBase {
kind: "segmented";
default: V;
options: { value: V; label: string }[];
}
export interface TextSpec extends FieldSpecBase {
kind: "text";
default: string;
placeholder?: string;
}
export interface CheckboxSpec extends FieldSpecBase {
kind: "checkbox";
default: boolean;
}
/** Composite "dimensions" field: width + height as one object. */
export interface Dimension {
width: number;
height: number;
}
/** Single list of tile presets (squares) for dimension fields. */
export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const;
export interface DimensionSpec extends FieldSpecBase {
kind: "dimension";
/** Shared range for both dimensions. */
min: number;
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
/** Per-axis cap from the matching source dimension. */
maxFromSource?: boolean;
/** Id of the "keep aspect ratio" checkbox field. */
lockAspectWith?: string;
/** Show quick-pick chips from SIZE_PRESETS. */
presets?: boolean;
}
export interface SchemaContext {
source?: Dimension;
}
/** Color pair "from -> to" (gradients, two-color mapping, etc.). */
export interface ColorPair {
from: string;
to: string;
}
export interface ColorPairSpec extends FieldSpecBase {
kind: "color-pair";
from: string;
to: string;
}
/** Color list (palette): array of hex strings. */
export type ColorList = string[];
export interface ColorListSpec extends FieldSpecBase {
kind: "colors";
default: string[];
}
/** Shape/object offset from center: x + y in percent. */
export interface Offset {
x: number;
y: number;
}
export interface OffsetSpec extends FieldSpecBase {
kind: "offset";
/** Shared range for both axes (in percent). */
min: number;
max: number;
x: number;
y: number;
}
/** 9-position grid (3x3); values match `Position9` from core/textdraw. */
export const POSITION9_VALUES = [
"top-left",
"top-center",
"top-right",
"middle-left",
"center",
"middle-right",
"bottom-left",
"bottom-center",
"bottom-right",
] as const;
export interface Position9Spec extends FieldSpecBase {
kind: "position9";
default: Position9;
}
/** Text caption style: font, size, bold, and color as one object. */
export interface FontStyle {
font: TextFont;
size: number;
bold: boolean;
color: string;
}
export interface FontStyleSpec extends FieldSpecBase {
kind: "font-style";
/** Font size range. */
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}
/** Backing plate under a text caption: on/off, color, and opacity. */
export interface Plate {
enabled: boolean;
color: string;
opacity: number;
}
export interface PlateSpec extends FieldSpecBase {
kind: "plate";
enabled: boolean;
color: string;
opacity: number;
}
/** Color gradient: pair + angle in degrees (0: left-to-right, 90: top-to-bottom). */
export interface Gradient {
from: string;
to: string;
angle: number;
}
export interface GradientSpec extends FieldSpecBase {
kind: "gradient";
from: string;
to: string;
/** Gradient direction: 0..360, angle in degrees. */
angle: number;
}
/** kind -> field spec map: single source of `FieldSpecKind`/`FieldSpec`. */
export const fieldSpecs = {
number: {} as NumberSpec,
slider: {} as SliderSpec,
color: {} as ColorSpec,
select: {} as SelectSpec,
segmented: {} as SegmentedSpec,
text: {} as TextSpec,
checkbox: {} as CheckboxSpec,
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
colors: {} as ColorListSpec,
offset: {} as OffsetSpec,
position9: {} as Position9Spec,
"font-style": {} as FontStyleSpec,
plate: {} as PlateSpec,
gradient: {} as GradientSpec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
export type FieldSpecOf<K extends FieldSpecKind> = (typeof fieldSpecs)[K];
export type FieldSpec = FieldSpecOf<FieldSpecKind>;
/** `Field<T>`: runtime field spec + phantom type of the expected value (number|string|boolean). */
export interface Field<T> {
spec: FieldSpec;
/** Phantom, for type safety only; never set or read at runtime. */
__fieldT?: T;
}
export interface ToolLayoutGroup {
/** Group title (an empty group is not rendered). */
title?: string;
/** Grid column count inside the group (default 1). */
cols?: number;
/** Schema field names belonging to the group. */
fields: string[];
}
export interface ToolSchemaLayout {
/** Widget field groups. Unmentioned fields go to a trailing default group. */
groups: ToolLayoutGroup[];
}
export interface ToolSchemaMeta {
/** Per-tool field layout (grouping/columns). */
layout?: ToolSchemaLayout;
/** Short tool label for the UI (optional). */
label?: string;
}
export interface ToolSchema<P> {
fields: { [K in keyof P]: Field<P[K]> };
layout?: ToolSchemaLayout;
label?: string;
}
+291
View File
@@ -0,0 +1,291 @@
import { clamp } from "../core/math";
import type { Position9 } from "../core/textdraw";
import { dimensionAxisMax, effectiveMax } from "./aspect";
import {
POSITION9_VALUES,
type Dimension,
type DimensionSpec,
type SchemaContext,
type ToolSchema,
} from "./specs";
function resolveDimensionDefaults(
spec: DimensionSpec,
context?: SchemaContext,
): Dimension {
const source = spec.defaultFromSource ? context?.source : undefined;
return {
width: clamp(source?.width ?? spec.width, spec.min, spec.max),
height: clamp(source?.height ?? spec.height, spec.min, spec.max),
};
}
/** Defaults from the schema: the single source of default values for the UI. */
export function defaultSchemaParams<P>(
schema: ToolSchema<P>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "dimension") {
out[key] = resolveDimensionDefaults(spec, context);
} else if (spec.kind === "color-pair") {
out[key] = { from: spec.from, to: spec.to };
} else if (spec.kind === "colors") {
out[key] = [...spec.default];
} else if (spec.kind === "offset") {
out[key] = { x: spec.x, y: spec.y };
} else if (spec.kind === "font-style") {
out[key] = {
font: spec.font,
size: spec.size,
bold: spec.bold,
color: spec.color,
};
} else if (spec.kind === "plate") {
out[key] = {
enabled: spec.enabled,
color: spec.color,
opacity: spec.opacity,
};
} else if (spec.kind === "gradient") {
out[key] = {
from: spec.from,
to: spec.to,
angle: spec.angle,
};
} else {
out[key] = spec.default;
}
}
return out;
}
/**
* Recomputes defaults from a new source, preserving user values. Fields in
* touched (edited manually) are not overwritten even with a source default;
* otherwise loading a new file would wipe configured sizes.
*/
export function applySourceDefaults<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context: SchemaContext,
touched?: ReadonlySet<string>,
): Record<keyof P, unknown> {
const defaults = defaultSchemaParams(schema, context);
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
const fromSource = spec.kind === "dimension" && spec.defaultFromSource;
if (fromSource && !touched?.has(key as string)) continue;
if (Object.prototype.hasOwnProperty.call(values, key)) {
defaults[key] = values[key as string];
}
}
return defaults;
}
/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */
export function sanitizeSchemaParams<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
const raw = values[key as string];
switch (spec.kind) {
case "number":
case "slider": {
const n =
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
const max =
spec.kind === "number"
? effectiveMax(spec, context?.source)
: spec.max;
out[key] = clamp(n, spec.min ?? -Infinity, max ?? Infinity);
break;
}
case "select":
case "segmented":
out[key] =
typeof raw === "string" && spec.options.some((o) => o.value === raw)
? raw
: spec.default;
break;
case "checkbox":
out[key] = typeof raw === "boolean" ? raw : spec.default;
break;
case "color":
out[key] =
typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw)
? raw
: spec.default;
break;
case "text":
out[key] = typeof raw === "string" ? raw : spec.default;
break;
case "dimension": {
const r =
typeof raw === "object" &&
raw !== null &&
"width" in raw &&
"height" in raw
? (raw as Record<string, unknown>)
: undefined;
const defaults = resolveDimensionDefaults(spec, context);
const w =
typeof r?.width === "number" && Number.isFinite(r.width)
? r.width
: defaults.width;
const h =
typeof r?.height === "number" && Number.isFinite(r.height)
? r.height
: defaults.height;
const sourceSize =
spec.defaultFromSource && context?.source && w === 0 && h === 0
? defaults
: { width: w, height: h };
out[key] = {
width: clamp(
sourceSize.width,
spec.min,
dimensionAxisMax(spec, "width", context?.source),
),
height: clamp(
sourceSize.height,
spec.min,
dimensionAxisMax(spec, "height", context?.source),
),
};
break;
}
case "color-pair": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw
? (raw as Record<string, unknown>)
: undefined;
const from =
typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from)
? r.from
: spec.from;
const to =
typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to)
? r.to
: spec.to;
out[key] = { from, to };
break;
}
case "colors": {
const r =
Array.isArray(raw) &&
raw.every((c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c))
? (raw as string[])
: undefined;
out[key] = r && r.length > 0 ? [...r] : [...spec.default];
break;
}
case "position9":
out[key] =
typeof raw === "string" &&
(POSITION9_VALUES as readonly string[]).includes(raw)
? (raw as Position9)
: spec.default;
break;
case "font-style": {
const r =
typeof raw === "object" &&
raw !== null &&
"font" in raw &&
"size" in raw &&
"bold" in raw &&
"color" in raw
? (raw as Record<string, unknown>)
: undefined;
out[key] = {
font:
r?.font === "sans" || r?.font === "serif" || r?.font === "mono"
? r.font
: spec.font,
size:
typeof r?.size === "number" && Number.isFinite(r.size)
? clamp(r.size, spec.min, spec.max)
: spec.size,
bold: typeof r?.bold === "boolean" ? r.bold : spec.bold,
color:
typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color)
? r.color
: spec.color,
};
break;
}
case "plate": {
const r =
typeof raw === "object" &&
raw !== null &&
"enabled" in raw &&
"color" in raw &&
"opacity" in raw
? (raw as Record<string, unknown>)
: undefined;
out[key] = {
enabled: typeof r?.enabled === "boolean" ? r.enabled : spec.enabled,
color:
typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color)
? r.color
: spec.color,
opacity:
typeof r?.opacity === "number" && Number.isFinite(r.opacity)
? clamp(r.opacity, 0, 100)
: spec.opacity,
};
break;
}
case "offset": {
const r =
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
? (raw as Record<string, unknown>)
: undefined;
const x =
typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x;
const y =
typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y;
out[key] = {
x: clamp(x, spec.min, spec.max),
y: clamp(y, spec.min, spec.max),
};
break;
}
case "gradient": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw &&
"angle" in raw
? (raw as Record<string, unknown>)
: undefined;
out[key] = {
from:
typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from)
? r.from
: spec.from,
to:
typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to)
? r.to
: spec.to,
angle:
typeof r?.angle === "number" && Number.isFinite(r.angle)
? clamp(r.angle, 0, 360)
: spec.angle,
};
break;
}
}
}
return out;
}
+7 -2
View File
@@ -50,7 +50,9 @@ export const TOOLS: Tool[] = [
...textTools, ...textTools,
] as unknown as Tool[]; ] as unknown as Tool[];
export const PAGES: Page[] = [ // Not annotated as Page[] on purpose: the literal slug types must survive
// for PageSlug below; every consumer treats PAGES as a readonly Page list.
export const PAGES = [
...geometryPages, ...geometryPages,
...alphaPages, ...alphaPages,
...convertPages, ...convertPages,
@@ -59,7 +61,10 @@ export const PAGES: Page[] = [
...colorPages, ...colorPages,
...generatePages, ...generatePages,
...textPages, ...textPages,
] as unknown as Page[]; ];
/** Union of existing page slugs (groups use `satisfies Page[]`, so literals survive). */
export type PageSlug = (typeof PAGES)[number]["slug"];
export function getTool(id: string): Tool | undefined { export function getTool(id: string): Tool | undefined {
return TOOLS.find((tool) => tool.id === id); return TOOLS.find((tool) => tool.id === id);
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const alphaPages: Page[] = [ export const alphaPages = [
{ {
slug: "add-stroke-png", slug: "add-stroke-png",
title: "Outline PNG", title: "Outline PNG",
@@ -157,4 +157,4 @@ export const alphaPages: Page[] = [
category: "alpha", category: "alpha",
steps: [{ id: "close-holes" }], steps: [{ id: "close-holes" }],
}, },
]; ] as const satisfies readonly Page[];
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const analyzePages: Page[] = [ export const analyzePages = [
{ {
slug: "extract-color-from-png", slug: "extract-color-from-png",
title: "Extract Color from PNG", title: "Extract Color from PNG",
@@ -93,4 +93,4 @@ export const analyzePages: Page[] = [
category: "analyze", category: "analyze",
steps: [{ id: "orientation" }], steps: [{ id: "orientation" }],
}, },
]; ] as const satisfies readonly Page[];
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const colorPages: Page[] = [ export const colorPages = [
{ {
slug: "two-colors-png", slug: "two-colors-png",
title: "Two colors PNG", title: "Two colors PNG",
@@ -199,4 +199,4 @@ export const colorPages: Page[] = [
category: "color", category: "color",
steps: [{ id: "to-lab" }], steps: [{ id: "to-lab" }],
}, },
]; ] as const satisfies readonly Page[];
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const convertPages: Page[] = [ export const convertPages = [
{ {
slug: "convert-png-to-jpg", slug: "convert-png-to-jpg",
title: "Convert PNG to JPG", title: "Convert PNG to JPG",
@@ -112,4 +112,4 @@ export const convertPages: Page[] = [
category: "convert", category: "convert",
steps: [{ id: "from-svg" }], steps: [{ id: "from-svg" }],
}, },
]; ] as const satisfies readonly Page[];
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const filtersPages: Page[] = [ export const filtersPages = [
{ {
slug: "blur-png", slug: "blur-png",
title: "Blur PNG", title: "Blur PNG",
@@ -65,4 +65,4 @@ export const filtersPages: Page[] = [
category: "filters", category: "filters",
steps: [{ id: "jpeg-artifacts" }], steps: [{ id: "jpeg-artifacts" }],
}, },
]; ] as const satisfies readonly Page[];
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const generatePages: Page[] = [ export const generatePages = [
{ {
slug: "create-empty-png", slug: "create-empty-png",
title: "Create empty PNG", title: "Create empty PNG",
@@ -164,4 +164,4 @@ export const generatePages: Page[] = [
category: "generate", category: "generate",
steps: [{ id: "from-text" }], steps: [{ id: "from-text" }],
}, },
]; ] as const satisfies readonly Page[];
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const geometryPages: Page[] = [ export const geometryPages = [
{ {
slug: "add-border-png", slug: "add-border-png",
title: "Add border to PNG", title: "Add border to PNG",
@@ -149,4 +149,4 @@ export const geometryPages: Page[] = [
category: "geometry", category: "geometry",
steps: [{ id: "shift" }], steps: [{ id: "shift" }],
}, },
]; ] as const satisfies readonly Page[];
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Page } from "../types"; import type { Page } from "../types";
export const textPages: Page[] = [ export const textPages = [
{ {
slug: "add-text-png", slug: "add-text-png",
title: "Add text to PNG", title: "Add text to PNG",
@@ -25,4 +25,4 @@ export const textPages: Page[] = [
category: "text", category: "text",
steps: [{ id: "watermark-tile" }], steps: [{ id: "watermark-tile" }],
}, },
]; ] as const satisfies readonly Page[];
+3 -1
View File
@@ -48,7 +48,9 @@ describe("registry-new (migrated tools)", () => {
}); });
it("every tool is used by at least one page", () => { it("every tool is used by at least one page", () => {
const used = new Set(PAGES.flatMap((page) => page.steps.map((s) => s.id))); const used = new Set<string>(
PAGES.flatMap((page) => page.steps.map((s) => s.id)),
);
const orphans = TOOLS.map((tool) => tool.id).filter((id) => !used.has(id)); const orphans = TOOLS.map((tool) => tool.id).filter((id) => !used.has(id));
expect(orphans, `tools without pages:\n${orphans.join("\n")}`).toEqual([]); expect(orphans, `tools without pages:\n${orphans.join("\n")}`).toEqual([]);
}); });
+1 -1
View File
@@ -104,7 +104,7 @@ describe("docs/tools-map.md vs registry pages", () => {
const markdown = readFileSync(MAP_PATH, "utf8"); const markdown = readFileSync(MAP_PATH, "utf8");
const mentioned = mentionedIds(markdown); const mentioned = mentionedIds(markdown);
const planned = plannedIds(markdown); const planned = plannedIds(markdown);
const registrySlugs = new Set(PAGES.map((page) => page.slug)); const registrySlugs = new Set<string>(PAGES.map((page) => page.slug));
it("parses a non-empty slug list (parser did not silently break)", () => { it("parses a non-empty slug list (parser did not silently break)", () => {
expect(mentioned.length).toBeGreaterThan(0); expect(mentioned.length).toBeGreaterThan(0);
+2 -2
View File
@@ -9,9 +9,9 @@ import { changeCanvasSize } from "../../core/geometry";
import { import {
analogousSet, analogousSet,
complementarySet, complementarySet,
hexToRgb,
mixColors, mixColors,
monochromaticSet, monochromaticSet,
parseHexColor,
renderBlend, renderBlend,
renderSwatches, renderSwatches,
renderWheel, renderWheel,
@@ -33,7 +33,7 @@ import {
import { genTool, type Tool } from "../types"; import { genTool, type Tool } from "../types";
function rgba(hex: string): [number, number, number, number] { function rgba(hex: string): [number, number, number, number] {
const { r, g, b } = hexToRgb(hex); const { r, g, b } = parseHexColor(hex);
return [r, g, b, 255]; return [r, g, b, 255];
} }
+5 -5
View File
@@ -80,12 +80,12 @@ export type PageStep = {
/** Page: unique address, strings, and the ordered tool list in `steps` (a page may be a ready-made chain run by `SchemaToolView`). */ /** Page: unique address, strings, and the ordered tool list in `steps` (a page may be a ready-made chain run by `SchemaToolView`). */
export type Page = { export type Page = {
/** Address `/tools/<slug>`, downloaded file name, and icon key. */ /** Address `/tools/<slug>`, downloaded file name, and icon key. */
slug: string; readonly slug: string;
/** EN string; the translation lives in the dictionary by slug. */ /** EN string; the translation lives in the dictionary by slug. */
title: string; readonly title: string;
description: string; readonly description: string;
category: CategoryId; readonly category: CategoryId;
steps: PageStep[]; readonly steps: readonly PageStep[];
}; };
export function requireSource<P>(ctx: ToolContext<P>): PixelImage { export function requireSource<P>(ctx: ToolContext<P>): PixelImage {
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { makeImage } from "./core/test-helpers";
import type { PixelImage } from "./core/types";
import type { ExecuteContext } from "./executor";
import { getTool, type Tool, type ToolImageFile } from "./registry";
import { ChainStepError, glueName, runChain, uniqueName } from "./run-chain";
const realExecute = (tool: Tool, ctx: ExecuteContext) =>
Promise.resolve(tool.run(ctx as never));
function named(name: string, image: PixelImage): ToolImageFile {
return { name, image };
}
const img4x2 = makeImage(4, 2, new Array(8).fill([100, 100, 100, 255]));
const img2x2 = makeImage(2, 2, new Array(4).fill([200, 50, 0, 255]));
describe("runChain", () => {
it("single image through an image step returns a plain image", async () => {
const r = await runChain(
[{ id: "grayscale", params: {} }],
[named("photo.png", img2x2)],
undefined,
realExecute,
);
expect("data" in (r.out as PixelImage)).toBe(true);
expect(r.stepResults[0]?.width).toBe(2);
expect(r.warnings).toEqual([]);
});
it("split mid-chain fans out: next step applies to every part", async () => {
const r = await runChain(
[
{
id: "split-into-parts",
params: { mode: "grid", columns: 2, rows: 1 },
},
{ id: "grayscale", params: {} },
],
[named("img.png", img4x2)],
undefined,
realExecute,
);
const files = (r.out as { files: ToolImageFile[] }).files;
expect(files).toHaveLength(2);
expect(files.map((f) => f.name)).toEqual([
"img-part-1-1.png",
"img-part-1-2.png",
]);
// full per-step sets: split -> 2 parts, grayscale -> 2 images
expect(r.stepFileSets.map((s) => s.length)).toEqual([2, 2]);
// grayscale is deterministic: part pixels are gray both in R and B
const px = files[0].image.data;
expect(px[0]).toBe(px[2]);
});
it("batch input: one image step over many files yields a named set", async () => {
const r = await runChain(
[{ id: "grayscale", params: {} }],
[named("a.png", img2x2), named("b.png", img2x2)],
undefined,
realExecute,
);
const files = (r.out as { files: ToolImageFile[] }).files;
expect(files.map((f) => f.name)).toEqual(["a.png", "b.png"]);
expect(r.stepResults[0]).toBe(files[0].image);
});
it("partial failure: the step continues, warning reports the count", async () => {
const flaky = (tool: Tool, ctx: ExecuteContext) => {
if (ctx.source?.data[0] === 200) return Promise.reject(new Error("bad"));
return realExecute(tool, ctx);
};
const r = await runChain(
[{ id: "grayscale", params: {} }],
[named("ok.png", img4x2), named("bad.png", img2x2)],
undefined,
flaky,
);
// a single survivor degrades to a plain image result
expect("data" in (r.out as PixelImage)).toBe(true);
expect(r.warnings).toEqual([{ kind: "partial", step: 1, ok: 1, total: 2 }]);
});
it("total failure: ChainStepError with the step index", async () => {
const failing = () => Promise.reject(new Error("nope"));
await expect(
runChain(
[{ id: "grayscale", params: {} }],
[named("a.png", img2x2)],
undefined,
failing,
),
).rejects.toSatisfy(
(e) => e instanceof ChainStepError && e.stepIndex === 0,
);
});
it("analyze step over a batch sees only the first file", async () => {
const r = await runChain(
[{ id: "is-transparent", params: {} }],
[named("a.png", img2x2), named("b.png", img2x2)],
undefined,
realExecute,
);
expect(r.warnings).toEqual([{ kind: "firstOnly", step: 1, total: 2 }]);
expect(r.out).toBe("transparentNo");
});
});
describe("glueName", () => {
it("strips the parent extension, keeps the part name", () => {
expect(glueName("photo.jpeg", "part-1-1.png")).toBe("photo-part-1-1.png");
});
});
describe("uniqueName", () => {
it("keeps the first, suffixes duplicates", () => {
const used = new Set<string>();
const a = uniqueName("image.png", used);
used.add(a);
const b = uniqueName("image.png", used);
used.add(b);
const c = uniqueName("image.png", used);
expect([a, b, c]).toEqual(["image.png", "image-2.png", "image-3.png"]);
});
});
+165
View File
@@ -0,0 +1,165 @@
import type { PixelImage } from "./core/types";
import type { ExecuteContext } from "./executor";
import {
getTool,
type FileResult,
type Tool,
type ToolImageFile,
type ToolResult,
} from "./registry";
import type { Dimension } from "./registry-schema";
export type ChainExecutor = (
tool: Tool,
ctx: ExecuteContext,
) => Promise<ToolResult>;
export type ChainWarning =
| { kind: "partial"; step: number; ok: number; total: number }
| { kind: "firstOnly"; step: number; total: number }
| { kind: "sourceSkip"; skipped: number; total: number };
export class ChainStepError extends Error {
constructor(
readonly stepIndex: number,
readonly toolId: string,
readonly cause: unknown,
) {
super(`step ${stepIndex + 1} (${toolId}) failed`);
}
}
export interface ChainRunOutput {
stepResults: (PixelImage | null)[];
/** Full named set per step; empty for text/verdict/none steps. */
stepFileSets: ToolImageFile[][];
stepDims: (Dimension | undefined)[];
out: ToolResult;
warnings: ChainWarning[];
}
/** Zip entries are encoded as PNG (see zip.ts), so names stay .png. */
export function baseName(name: string): string {
return name.replace(/\.[^./\\]+$/, "");
}
export function glueName(parent: string, part: string): string {
return `${baseName(parent)}-${part}`;
}
/**
* Zip entries and keyed lists address files by name, so duplicates must not
* occur: "image.png" repeated -> "image-2.png", "image-3.png".
*/
export function uniqueName(name: string, used: Set<string>): string {
if (!used.has(name)) return name;
const base = baseName(name);
const ext = name.slice(base.length);
for (let n = 2; ; n++) {
const candidate = `${base}-${n}${ext}`;
if (!used.has(candidate)) return candidate;
}
}
function isImage(out: ToolResult): out is PixelImage {
return typeof out === "object" && out !== null && "data" in out;
}
/**
* Fan-out chain runner: image steps apply to every file of the set,
* files-steps glue part names, analyze steps see only the first file.
* A step fails the chain only when every file fails (ChainStepError).
*/
export async function runChain(
steps: { id: string; params: Record<string, unknown> }[],
source: ToolImageFile[],
textSource: string | undefined,
execute: ChainExecutor,
): Promise<ChainRunOutput> {
const stepResults: (PixelImage | null)[] = [];
const stepFileSets: ToolImageFile[][] = [];
const stepDims: (Dimension | undefined)[] = [];
const warnings: ChainWarning[] = [];
let current: ToolImageFile[] = source;
let out: ToolResult = "";
for (let i = 0; i < steps.length; i++) {
const tool = getTool(steps[i].id);
if (!tool) continue;
if (tool.input === "text" || tool.input === "none") {
out = await execute(tool, {
params: steps[i].params,
text: tool.input === "text" ? textSource : undefined,
});
if (isImage(out)) {
current = [{ name: tool.id, image: out }];
stepResults[i] = out;
stepFileSets[i] = current;
stepDims[i] = { width: out.width, height: out.height };
} else {
current = [];
stepResults[i] = null;
stepFileSets[i] = [];
}
continue;
}
const analyze = tool.result === "text" || tool.result === "verdict";
const inputs =
analyze && current.length > 1 ? current.slice(0, 1) : current;
if (analyze && current.length > 1) {
warnings.push({ kind: "firstOnly", step: i + 1, total: current.length });
}
const produced: ToolImageFile[] = [];
let failed = 0;
let firstError: unknown;
for (const file of inputs) {
try {
const res = await execute(tool, {
params: steps[i].params,
source: file.image,
});
out = res;
if (isImage(res)) {
produced.push({ name: file.name, image: res });
} else if (typeof res === "object" && res !== null && "files" in res) {
produced.push(
...res.files.map((f) => ({
name: glueName(file.name, f.name),
image: f.image,
})),
);
}
} catch (e) {
failed++;
firstError ??= e;
}
}
if (inputs.length > 0 && failed === inputs.length) {
throw new ChainStepError(i, tool.id, firstError);
}
if (failed > 0) {
warnings.push({
kind: "partial",
step: i + 1,
ok: inputs.length - failed,
total: inputs.length,
});
}
current = produced;
stepResults[i] = produced[0]?.image ?? null;
stepFileSets[i] = produced;
stepDims[i] = produced[0]
? { width: produced[0].image.width, height: produced[0].image.height }
: undefined;
}
if (current.length > 1) {
out = { files: current } satisfies FileResult;
} else if (current.length === 1) {
out = current[0].image;
}
return { stepResults, stepFileSets, stepDims, out, warnings };
}
+12
View File
@@ -0,0 +1,12 @@
export function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
// setItem throws in privacy modes; persistence is best-effort.
export function safeSetItem(key: string, value: string): void {
try {
storage()?.setItem(key, value);
} catch {
// ignore
}
}
+3 -5
View File
@@ -1,13 +1,11 @@
import { safeSetItem } from "./storage";
export type ThemeChoice = "light" | "dark"; export type ThemeChoice = "light" | "dark";
const STORAGE_KEY = "theme"; const STORAGE_KEY = "theme";
let theme = $state<ThemeChoice>("light"); let theme = $state<ThemeChoice>("light");
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
function systemTheme(): ThemeChoice { function systemTheme(): ThemeChoice {
if (typeof window === "undefined" || !window.matchMedia) return "light"; if (typeof window === "undefined" || !window.matchMedia) return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches return window.matchMedia("(prefers-color-scheme: dark)").matches
@@ -26,7 +24,7 @@ export function getTheme(): ThemeChoice {
export function setTheme(next: ThemeChoice): void { export function setTheme(next: ThemeChoice): void {
theme = next; theme = next;
storage()?.setItem(STORAGE_KEY, next); safeSetItem(STORAGE_KEY, next);
apply(); apply();
} }
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { PAGES } from "./registry";
import { TOOL_ICONS } from "./tool-icons";
describe("tool-icons", () => {
it("every icon key is an existing page slug", () => {
const slugs = new Set<string>(PAGES.map((page) => page.slug));
const orphans = Object.keys(TOOL_ICONS).filter((key) => !slugs.has(key));
expect(orphans).toEqual([]);
});
it("every page has an icon", () => {
const missing = PAGES.map((page) => page.slug).filter(
(slug) => !(slug in TOOL_ICONS),
);
expect(missing).toEqual([]);
});
});
+12 -13
View File
@@ -1,5 +1,4 @@
import { import {
AppWindow,
Binary, Binary,
Blend, Blend,
CalendarDays, CalendarDays,
@@ -15,7 +14,6 @@ import {
FileImage, FileImage,
FileOutput, FileOutput,
FilePlus2, FilePlus2,
Film,
FlipHorizontal2, FlipHorizontal2,
Focus, Focus,
Frame, Frame,
@@ -27,7 +25,6 @@ import {
Layers, Layers,
Link2, Link2,
Maximize2, Maximize2,
Minimize2,
Moon, Moon,
PaintBucket, PaintBucket,
Palette, Palette,
@@ -49,8 +46,13 @@ import {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
} from "@lucide/svelte"; } from "@lucide/svelte";
import type { Component } from "svelte";
import type { PageSlug } from "./registry";
export const TOOL_ICONS: Record<string, typeof AppWindow> = { type IconComponent = Component<{ size?: number; class?: string }>;
// Keyed by page slug: a renamed/missing page is a compile error here.
export const TOOL_ICONS: Record<PageSlug, IconComponent> = {
"resize-png": Scaling, "resize-png": Scaling,
"crop-png": Crop, "crop-png": Crop,
"rotate-png": RotateCw, "rotate-png": RotateCw,
@@ -61,12 +63,6 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
"convert-png-to-jpg": FileImage, "convert-png-to-jpg": FileImage,
"convert-png-to-webp": FileImage, "convert-png-to-webp": FileImage,
"remove-color-from-png": Scissors, "remove-color-from-png": Scissors,
"png-info": Info,
"jpg-to-png": FileImage,
"webp-to-png": FileImage,
"gif-to-png": Film,
"bmp-to-png": FileImage,
"ico-to-png": AppWindow,
"png-to-bmp": FileOutput, "png-to-bmp": FileOutput,
"png-to-base64": Binary, "png-to-base64": Binary,
"base64-to-png": ClipboardPaste, "base64-to-png": ClipboardPaste,
@@ -134,8 +130,6 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
"change-aspect-ratio-png": Scaling, "change-aspect-ratio-png": Scaling,
"swap-orientation-png": RotateCw, "swap-orientation-png": RotateCw,
"symmetric-copy-png": FlipHorizontal2, "symmetric-copy-png": FlipHorizontal2,
"compress-png": Minimize2,
"reduce-to-size-png": Scaling,
"png-file-size": Info, "png-file-size": Info,
"png-to-bytes": Binary, "png-to-bytes": Binary,
"bytes-to-png": Binary, "bytes-to-png": Binary,
@@ -153,7 +147,6 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
"star-mask-png": Star, "star-mask-png": Star,
"wavy-mask-png": WavesHorizontal, "wavy-mask-png": WavesHorizontal,
"watermark-tile-png": Stamp, "watermark-tile-png": Stamp,
"watermark-image-png": FileImage,
"color-wheel-png": Rainbow, "color-wheel-png": Rainbow,
"complementary-png": Contrast, "complementary-png": Contrast,
"triadic-png": Hash, "triadic-png": Hash,
@@ -183,3 +176,9 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
"tint-png": Droplet, "tint-png": Droplet,
"svg-to-png": FileImage, "svg-to-png": FileImage,
}; };
// Slugs come from the registry at runtime (Page.slug is string there), so the
// lookup widens; completeness is guaranteed by the Record<PageSlug, _> above.
export function toolIcon(slug: string): IconComponent {
return TOOL_ICONS[slug as PageSlug];
}
+3 -3
View File
@@ -13,7 +13,7 @@
} 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 { PAGES } from "$lib/registry"; import { PAGES } from "$lib/registry";
import { TOOL_ICONS } from "$lib/tool-icons"; import { toolIcon } from "$lib/tool-icons";
let query = $state(""); let query = $state("");
let category = $state<string>("all"); let category = $state<string>("all");
@@ -52,7 +52,7 @@
slug={page.slug} slug={page.slug}
description={pageDescription(page)} description={pageDescription(page)}
index={i + 1} index={i + 1}
icon={TOOL_ICONS[page.slug]} icon={toolIcon(page.slug)}
/> />
{/each} {/each}
</CatalogGroup> </CatalogGroup>
@@ -65,7 +65,7 @@
slug={page.slug} slug={page.slug}
description={pageDescription(page)} description={pageDescription(page)}
index={i + 1} index={i + 1}
icon={TOOL_ICONS[page.slug]} icon={toolIcon(page.slug)}
/> />
{/each} {/each}
</CatalogGroup> </CatalogGroup>
+3 -3
View File
@@ -13,7 +13,7 @@
} 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 { PAGES } from "$lib/registry"; import { PAGES } from "$lib/registry";
import { TOOL_ICONS } from "$lib/tool-icons"; import { toolIcon } from "$lib/tool-icons";
let query = $state(""); let query = $state("");
let category = $state<string>("all"); let category = $state<string>("all");
@@ -56,7 +56,7 @@
slug={page.slug} slug={page.slug}
description={pageDescription(page)} description={pageDescription(page)}
index={i + 1} index={i + 1}
icon={TOOL_ICONS[page.slug]} icon={toolIcon(page.slug)}
/> />
{/each} {/each}
</CatalogGroup> </CatalogGroup>
@@ -69,7 +69,7 @@
slug={page.slug} slug={page.slug}
description={pageDescription(page)} description={pageDescription(page)}
index={i + 1} index={i + 1}
icon={TOOL_ICONS[page.slug]} icon={toolIcon(page.slug)}
/> />
{/each} {/each}
</CatalogGroup> </CatalogGroup>