mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b94feee79 | ||
|
|
6492ef6c61 | ||
|
|
2ddb348e54 | ||
|
|
8b2172c39a | ||
|
|
4eb657dd50 | ||
|
|
6288b03ac0 | ||
|
|
637f656229 | ||
|
|
0b929eced2 | ||
|
|
dfb1f531fa | ||
|
|
e51d6f3239 | ||
|
|
731f4ba90e | ||
|
|
421f078f90 | ||
|
|
0b3eeab959 | ||
|
|
6d683c1d75 | ||
|
|
551da338f3 | ||
|
|
52cb9c8a11 | ||
|
|
07c6475c21 | ||
|
|
d58f6c90be | ||
|
|
9916955b96 | ||
|
|
6dcbb7c0f8 | ||
|
|
58699abdf1 | ||
|
|
bac82f8442 |
@@ -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
|
||||||
|
|||||||
+38
-4
@@ -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`,
|
||||||
@@ -209,11 +223,11 @@ 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/` |
|
||||||
@@ -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)` без
|
||||||
|
монтирования компонента.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{#if sources.length > 1}
|
||||||
|
<PartsGrid files={sources} />
|
||||||
|
{:else}
|
||||||
<img
|
<img
|
||||||
data-testid="source-image"
|
data-testid="source-image"
|
||||||
src={sourceUrl}
|
src={sourceUrl}
|
||||||
alt={t("sourceCard.alt")}
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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,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)));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "Исходное изображение",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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)) };
|
||||||
|
}
|
||||||
@@ -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 ?? {}) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./aspect";
|
||||||
|
export * from "./field";
|
||||||
|
export * from "./layout";
|
||||||
|
export * from "./specs";
|
||||||
|
export * from "./values";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user