Compare commits

...
8 Commits
27 changed files with 2409 additions and 197 deletions
+23
View File
@@ -138,6 +138,29 @@ scoped-путям (`kit/**`, `preview/**`).
определён в `src/preview.css` (словарь токенов) или локально в компоненте.
Файл читается один раз и кэшируется (не `glob`-зависим).
### Изоляция веток old ↔ preview
`isolation/no-mixed-imports` (`web/eslint-plugins/isolation/no-mixed-imports.js`,
включён на весь `**/*.{ts,svelte}`) — **полная взаимная изоляция** старого UI и
новой preview-ветки. В отличие от `no-restricted-imports`, правило **резолвит**
каждый импорт (и `$lib/...`, и относительные `./`/`../`) до реального файла и
классифицирует стороны по фактическому пути, поэтому относительным импортом
правило не обойти.
- Старое: `routes/(old)/**`, `lib/registry.ts`, `lib/registry/**`,
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
`lib/components/**` (кроме `kit/`).
- Новое: `routes/preview/**`, `lib/registry-new/**`, `lib/preview/**`,
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`, `lib/components/kit/**`.
- Общее (разрешено обоим): `core/`, `i18n/`, `theme`, `assets/`, корневой `lib`.
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
glob-паттернов в настройке, а не код правила.
- Изоляция уже достигнута: старый `registry/` не тянет `registry-schema`
(пилоты add-border/add-stroke работают через `params`), `registry.ts` не
импортирует `ToolSchema`; preview-`list-tools` использует копию
`preview/tool-icons.ts`, а не старый `tools/tool-icons.ts`.
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
preview.css) — известный техдолг, его НЕ чинить и не игнорировать правилами.
+135 -23
View File
@@ -9,11 +9,32 @@
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
> step-colors, linear-gradient, two-colors); **составной тип `offset`
> полностью переведён** (4 инструмента: circle-mask, square-mask,
> star-mask, wavy-mask); preview
> star-mask, wavy-mask); **составной тип `position9` — переведены
> add-text-png и date-stamp-png** (водяной знак-картинка — отдельный шаг:
> требует overlay-механику в новом превью); **составной тип `font-style`
> полностью переведён** (text-to-png, add-text, date-stamp); **составной тип
> `plate` полностью переведён** (add-text, date-stamp); **составной тип
> `gradient` полностью переведён** (linear-gradient): вместо горизонтального/
> вертикального select — **угол 0..360°** (новый виджет `AngleControl`:
> slider + кнопки-пресеты 0°/90°/180°/270°, переиспользуемый компонент);
> preview
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
> color-pair/offset).
> Следующее: `position9` (add-text → date-stamp → watermark-image).
> color-pair/offset/position9/font-style/plate/gradient). **Пер-инструмент
> раскладка `schema.layout` реализована** (шаг 32): тип `ToolSchemaLayout`
> {groups: [{title?, cols?, fields}]}, рендер групп в `SchemaFields.svelte`
> (заголовок группы + сетка колонок, неупомянутые поля — в общей группе),
> пилот — `add-text` (группы Text/Placement/Plate). **UI-макеты расставлены
> по переведённым инструментам** (шаг 33): geometry (fit-on-background,
> change-canvas-size, resize, crop), маски alpha (Shape/Position), generate
> (create-empty, linear-gradient, color-spectrum, random-colors, draw-grid,
> step-colors, placeholder, text-to-png), text (add-text, date-stamp),
> filters (randomize-pixels, add-noise) — везде, где канвас отделён от
> параметров эффекта либо фигура от позиции.
> Следующее см. в `plan-migrate-remaining.md`: дожать перевод оставшихся
> 85 инструментов в preview, закрыть известные долги, переезд в папки `old/`
> (шаг 37, линтер-изоляция уже на месте) и последующая проверка в реальных
> условиях с фидбеком.
>
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
@@ -43,7 +64,7 @@
`lib/preview/categories.ts` (object as const) копирует и заменяет собой
`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и
остаётся без изменений. После перехода (Фаза 5) копия становится основной,
а исходник удаляется вместе со старым UI.
а исходник продолжает обслуживать `(old)` UI (он не удаляется — см. шаг 35).
**Разделение схем: две независимые схемы.**
@@ -63,8 +84,8 @@
Помимо двух схем, registry тоже разделён по UI (по факту миграции):
- **Старый registry** (`web/src/lib/registry.ts` + `registry/` +
`registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]` и
`tool-views.ts`. **Не трогаем**; идёт под удаление вместе со старым UI.
`registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]`.
**Не трогаем**; остаётся обслуживать `(old)/` маршруты (см. шаг 35).
- **Новый registry** (`web/src/lib/registry-new/`) — строится **с нуля «как надо»**:
`ToolEntry<P>` с обязательным `schema`, типизированный `run`, **без** `ParamDef[]`
и **без** связи со старым. Импортирует core-функции (`expandCanvas`,
@@ -362,25 +383,112 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
`offset: { x, y }` + `field.offset`, виджет
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
(default/sanitize). Тесты: +4 (600 passed).
27. `position9` — перевести add-text → date-stamp → watermark-image
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
date-stamp
29. `plate` — перевести add-text → date-stamp
30. `gradient` — собрать из dimension + color-pair + direction на
linear-gradient (зависит от решения по gradient, см. план)
27. `position9` — **переведены `add-text-png` и `date-stamp-png`** ✔ (run в
`registry-new/text.ts`, оба `domOnly`). Составной тип во всех видах:
значение — строка `Position9`, kind `position9` в схеме (default/sanitize
по `POSITION9_VALUES`), виджет 3×3 `kit/fields/schema/PositionControl.svelte`.
В `ToolEntry` добавлен флаг `domOnly` (превью-executor держит такие
инструменты вне worker). `watermark-image-png` — отдельный под-шаг:
ему нужен overlay-source (`getOverlay`/store), которого в новом превью пока
нет. Тесты: +2 (602 passed). `font-style` и `plate` на этих инструментах
сводятся в шаги 28-29.
28. `font-style` — **все 3 инструмента переведены** ✔ (`text-to-png` —
генератор в `registry-new/generate.ts` (domOnly), `add-text-png`/
`date-stamp-png` — рефакторинг в `registry-new/text.ts`). Составной тип
во всех видах: вложенный объект `style: { font, size, bold, color }` +
`field.fontStyle`, виджет `kit/fields/schema/FontStyleControl.svelte`,
kind `font-style` в схеме (default/sanitize, clamp размера к min/max).
Тесты: +1 (603 passed).
29. `plate` — **все 2 инструмента переведены** ✔ (`add-text-png`,
`date-stamp-png` — рефакторинг в `registry-new/text.ts`). Составной тип
во всех видах: вложенный объект `plate: { enabled, color, opacity }` +
`field.plate`, виджет `kit/fields/schema/PlateControl.svelte` (чекбокс +
цвет + слайдер непрозрачности, деактивируется при выключенной плашке),
kind `plate` в схеме (default/sanitize, clamp opacity к 0..100).
Покрытие тестов расширено (дефолты/sanitize plate в существующих тестах).
30. `gradient` — **переведён** ✔ (linear-gradient-png). Направление градиента
— не горизонталь/вертикаль, а **произвольный угол**: новый переиспользуемый
виджет `kit/fields/schema/AngleControl.svelte` (слайдер 0..360° + кнопки
стандартных углов 0°/90°/180°/270° с активным состоянием). Составной тип
`gradient: { from, to, angle }` во всех видах (`field.gradient`, kind
`gradient` в схеме — default/sanitize, clamp угла к 0..360, валидация
цветов; виджет `kit/fields/schema/GradientControl.svelte` — пара цветов +
AngleControl). Рендер по углу — локальный `angleGradient` в
`registry-new/generate.ts` (core-`gradientImage` не трогали: он умеет
только horizontal/vertical и используется старым UI). Семантика: 0° слева
направо, 90° сверху вниз, рост угла по часовой (ось Y вниз); нормализация
по полному диапазону проекции на ось (180°/270° разворачивают градиент).
Тесты: дефолты, направление по углу (0°/90°/180°), sanitize/кламп.
### Фаза 4 — масштаб UI на остальные инструменты
31. По мере перевода инструментов в Фазе 2-3 — рендер схемы (из Фазы 1)
покрывает их автоматически; составные виджеты (dimension, color-pair,
offset, position9, font-style, plate, gradient) — по одному, каждый с ревью.
32. Пер-инструмент layout (`schema.layout`): группировка полей рамки и т.п.
33. Для каждого переведённого инструмента — UI-макет, принимается отдельно.
32. Пер-инструмент layout (`schema.layout`) — **реализован** ✔.
`ToolSchemaLayout.groups: { title?, cols?, fields[] }` в registry-schema;
`SchemaFields.svelte` рендерит группы (подпись + сетка `1..n` колонок,
неупомянутые поля — в общей группе в конце, без заголовка). Пилот —
`add-text-png` (Text: text+style; Placement: position+margin; Plate).
Тесты: schema-layout в registry-schema.test.ts; полные запуски чисты.
33. Для каждого переведённого инструмента — UI-макет **расставлен** ✔.
Layout-группы заданы там, где группировка содержательна (канвас отдельно
от параметров эффекта, фигура отдельно от позиции; составные виджеты —
внутри групп целиком):
- geometry: fit-on-background (Canvas/Background), change-canvas-size
(Canvas/Anchor), resize (Canvas/Scaling), crop (Offset/Crop area);
- alpha-маски: circle/square/star/wavy (Shape — с 2 колонками где плотно,
Position — offset);
- generate: create-empty (Canvas/Fill), linear-gradient (Canvas/Colors),
color-spectrum (Canvas/Spectrum), random-colors (Canvas/Random),
draw-grid (Canvas/Grid), step-colors (Colors/Output), placeholder
(Canvas/Colors/Text), text-to-png (Text/Background/Padding);
- text: add-text, date-stamp (Text/Placement/Plate);
- filters: randomize-pixels (Blocks), add-noise (Noise/Seed).
Инструменты с 1–3 простыми полями остались без layout (одна общая группа).
### Фаза 5 — зачистка
### Фаза 5 — изоляция старого UI
34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента).
35. После перехода на новый дизайн — удаление старого UI и старого `ParamDef[]`.
34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента).
Отдельного `tool-views.ts` в репо нет: preview сразу строился на
`registry-new`. Meta инструмента живёт в `ToolEntry` (`title`, `description`,
`category`, `run`/`generate`), layout — в `schema.layout` (шаг 32), рендер —
`SchemaToolView`/`SchemaFields`/`SchemaPreview`. Дублирующей системы нет.
35. Старый UI **не удаляется**, а выносится в `(old)/`-маршруты и остаётся там
временно (посмотреть, как работает, сравнить с новым; старые тесты
продолжают проходить):
- маршруты `(old)/{+page,demo,list-tools,tools/[id]}` — старый дизайн,
тянет `old.css` (не `design2.css`), старый header/footer;
- старый `registry.ts`/`registry/` + `ParamDef[]` обслуживают только
`(old)/`-инструменты — не удаляются, не рефакторятся;
- удаление происходит позже, отдельным решением (когда новый UI покроет
все инструменты и ревью завершено).
36. **Линтер-изоляция веток** (гарантия, что old и preview не смешиваются) ✔.
Кастомный ESLint-плагин `web/eslint-plugins/isolation/no-mixed-imports`
Резолвит каждый импорт (и `$lib/...`, и относительные `./`/`../`) до
реального файла, классифицирует источник и цель по фактическому пути и
ругается на old→new и new→old. Конфигурация (`old`/`new` glob-паттерны,
`root`, `alias`) вынесена в настройки правила — единая точка правды:
- старое: `routes/(old)/**`, `lib/registry.ts`, `lib/registry/**`,
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
`lib/components/**` (кроме `kit/`);
- новое: `routes/preview/**`, `lib/registry-new/**`, `lib/preview/**`,
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`,
`lib/components/kit/**`;
- общее (разрешено обоим): всё прочее — `core/`, `i18n/`, `theme`,
`assets/`, корневой `lib` (`index.ts`, тесты).
Достигнутая полная изоляция (одиночные пересечения устранены):
- старые пилоты `registry/geometry.ts` (`add-border`) и `registry/alpha.ts`
(`add-stroke`) получали `schema` из нового `registry-schema` — убрано;
оба инструмента работают в старом UI через `params: ParamDef[]`,
в preview — через свои schema-версии в `registry-new/`;
- `registry.ts` больше не импортирует `ToolSchema` из `registry-schema`;
- preview `list-tools` тянул `TOOL_ICONS` из старого `lib/tools/tool-icons`
→ создана копия `lib/preview/tool-icons.ts` (правило копий).
37. Перенос всех старых компонентов/библиотек в папки `old/` (**следующее**):
переезд не трогает плагин — достаточно дописать один glob-паттерн в
настройку правила (например `lib/old/**`, `lib/components/old/**`), а сама
проверка работает по фактическим путям автоматически.
### Как ревьюить каждый шаг
@@ -417,8 +525,8 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
на `ParamDef[]` как раньше.
- **Новый UI** (kit/`SchemaToolView` + `SchemaFields` + `SchemaPreview`, читает
`ToolSchema<P>`) — сейчас рендерит поля по схеме (number/slider/color).
- `tool-views.ts` — со временем поглощается registry (preview/lede/layout
meta инструмента). Отдельный шаг, НЕ блокирует типизацию params.
- `tool-views.ts` отсутствует — preview/lede/layout уже живут в meta
инструмента (`ToolEntry` + `schema.layout`), дублирования нет.
## Оценка трудозатрат
@@ -431,15 +539,16 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
| Миграция инструментов на interface Params + схемы | Механическая, но крупная | ~8-12ч |
| Новый рендер (kit/ParamControl) + составные виджеты | Средняя | ~4-5ч |
| Поглощение tool-views.ts | Средняя | ~1-2ч |
| **Итого** | | **~20-29ч** (поэтапно) |
| Вынос старого UI в (old)/ + адаптация маршрутов | Средняя-Низкая | ~2-3ч |
| **Итого** | | **~22-32ч** (поэтапно) |
> Оценка выросла по сравнению с ранней версией плана, потому что принят путь
> «явный interface Params + схема + общий рендер с пер-инструмент layout» —
> это полный рефакторинг pipeline, а не только добавление составных типов.
>
> Старый UI/`ParamDef[]`/старый pipeline в смету **не входят** — они не
> рефакторятся, а продолжают работать до перехода (затем удаляются вместе со
> старым дизайном).
> рефакторятся, а продолжают работать на `(old)/`-маршрутах до перехода
> (затем убираются отдельным решением — см. шаг 35).
## Порядок реализации (кратко)
@@ -458,7 +567,10 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
отдельно.
5. **Фаза 4** — масштаб UI на остальные: составные виджеты, пер-инструмент
layout (для каждого инструмента — UI-макет).
6. **Фаза 5** — поглощение `tool-views.ts`, затем удаление старого UI/`ParamDef[]`
6. **Фаза 5** — изоляция: старый UI на `(old)/`-маршруты (не удаляется,
остаётся для ревью); preview на `registry-new` полностью;
линтер-изоляция веток (плагин `isolation`), затем перенос старых
компонентов/библиотек в папки `old/`.
## Зависимости
+202
View File
@@ -0,0 +1,202 @@
# План: дожать перевод инструментов в preview и переезд ветки `old/`
> Статус: **план.** Основа — `plan-composite-params.md` (Фазы 15). Текущее
> состояние: **40 из 125 инструментов переведены в `registry-new`**, старый UI
> уже живёт на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте
> (плагин `isolation/no-mixed-imports`). Здесь — что сделать, чтобы:
>
> 1. в preview был **весь** каталог инструментов (плюс закрыты известные долги);
> 2. физически перенести старую ветку в папки `old/` (шаг 37 основного плана);
> 3. после переезда провести детальную проверку в реальных условиях и собрать
> отзывы пользователей (правки поверх — отдельными задачами).
>
> Порядок: перевод остатка → починить известные долги → переезд в `old/` →
> пользовательская проверка/фидбек → (позже, отдельным решением) удаление
> старого кода.
## Текущая картина (факты)
- `registry.ts` + `registry/`**125 инструментов**; `registry-new/`**40**;
остаток — **85**. (Ранний подсчёт «104» был занижен: извлечение суффиксом
`-png` теряло каналы `png-to-hsl/…`, текстовые вердикты `png-info` и т.п. и
обратные конвертеры `png-to-base64/…`.)
- Preview (`/preview`) показывает только переведённые 40; старый UI работает
на `/`-маршрутах (группа `(old)/`): `+page`, `demo`, `list-tools`,
`tools/[id]`.
- Плагин `isolation/no-mixed-imports` (`web/eslint-plugins/isolation/`)
полностью разделяет ветки: старый `lib/registry.ts|/registry/**`,
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
`lib/components/**`(кроме `kit/`) vs новый `routes/preview/**`,
`lib/registry-new/**`, `lib/preview/**`, `lib/registry-schema.ts`,
`lib/components/kit/**`. Общее: `core/`, `i18n/`, `theme`, `assets/`,
корневой `lib`.
- Правило копий (из основного плана): файл, используемый `(old)`, не трогаем —
делаем копию в `lib/preview/` (пример: `lib/preview/tool-icons.ts`).
- Известные долги (предсуществующие, не создавались нами): ошибка
`ToolCard.svelte:19:36` (svelte-check), фейл `palette.test.ts` (ENOENT
`src/app.css` — из-за него `pnpm test` красный при 607 passed), ~335
design-tokens ошибок `lint:all` в старых kit-компонентах, a11y-warning в
`ColorField.svelte`, пустой ruleset в `Toggle.svelte`, неиспользуемые токены
в `preview.css`.
## Скоуп перевода: 85 инструментов осталось
По категориям (в скобках — сколько из категории уже переведено; подсчёт по
`registry/` vs `registry-new/`, без учёта генерируемых `SpaceId` и params):
- **color — 18** (из 25, переведено 7): grayscale-png, invert-colors-png,
adjust-brightness-contrast-png, auto-contrast-png, sepia-png, posterize-png,
black-and-white-png, decrease-color-count-png, extract-channel-png,
swap-channels-png, change-png-opacity, change-png-hue, плюс 6 канальных
`png-to-{hsl,hsv,hsi,cmyk,ycbcr,lab}` (генерируются из `SPACES`);
`compress-png`/`reduce-to-size-png` — нет, они в convert. Простое; канальные —
на `core/channels.ts` (`renderSpace`). Нюанс: `change-png-opacity`/`hue`
похожи на переведённые, но это отдельные инструменты, не дубли.
- **geometry — 13** (из 18): rotate, flip, skew, zoom, shift, add-padding, tile,
trim-empty-space, change-aspect-ratio, swap-orientation, symmetric-copy,
center-by-alpha, rotate-free. В основном простое (slider/select/color/checkbox).
Нюансы: `rotate-free` — угол 0..360 (посмотреть reuse `AngleControl` или
slider); `tile` — cols/rows (два числа, можно `dimension` или два slider);
`shift` — offsetX/Y в px + цвет (не путать с `offset`-процентами).
- **alpha — 14** (из 21): remove-alpha, set-alpha, extract-alpha-mask,
round-corners, invert-alpha, remove-background, make-thicker, make-thinner,
feather-edges, clean-edges, harden-alpha, despeckle-alpha, close-holes +
**`watermark-image-png`** (см. особый случай). Остальное простое.
- **generate — 10** (из 21): emoji-to-png (emoji+size); палитровые генераторы
color-wheel, complementary, analogous, triadic, tetradic, monochromatic,
shades (baseColor + count/spread — простое); **`mix-colors`** и
**`sort-colors`** — особые случаи (списки).
- **convert — 14** (из 16): text-source-инструменты
base64/data-uri/hex/bytes/rgb-values-to-png + обратные
png-to-base64/data-uri/hex/bytes/rgb-values/bmp — **особый случай** (нужен
text-источник в preview); `compress-png`/`reduce-to-size-png` — **особый
случай** (output/формат, связан с backlog №7); `svg-to-png` — width.
- **analyze — 12** (из 13): show-transparent, show-grayscale, show-color,
light/dark-pixel-mask, unique-color-mask (color+opacity+tolerance+mode) —
простое, частично переиспользуемый предикат; **`verify-is-png`** — текстовый
вердикт, особый случай; плюс 5 text-вердиктов `png-info`, `png-is-grayscale`,
`png-file-size`, `png-is-transparent`, `png-orientation` — тоже текстовый
вывод (см. text-source/вердикт-механику).
- **filters — 3** (из 8): blur (radius), sharpen (strength), silhouette
(color+threshold). Простое.
- **text — 1** (из 3): **`watermark-tile-png`** — text+font-style+opacity+angle+
stepX/stepY. Переиспользует font-style; угол — AngleControl-подобный.
### Особые случаи (требуют достройки фундамента, не просто перевод)
1. **`watermark-image-png`** — нужна overlay-механика: второе изображение-знак
(`getOverlay`/store), которое загружается на странице инструмента и
применяется поверх. В новом preview это единственный непереведённый
инструмент с источником-картинкой помимо основного входа. Учесть связь с
`overlay-store` (упомянут в `plan-redesign.md`).
2. **Text-source / text-вердикты** (base64/data-uri/hex/bytes/rgb-values-to-png
и обратные png-to-base64/…; аналитические `png-info`, `png-is-grayscale`,
`png-file-size`, `png-is-transparent`, `png-orientation`, `verify-is-png`) —
в старом UI это `text`-параметры, из которых собирается изображение, либо
текстовый результат вместо картинки. В новом preview нужно решить, как
подаётся текст-вход (текстовое поле/паста на странице инструмента) и как
показывается результат для вердиктных инструментов (в конверсиях — не
картинка, а текст/бейдж).
3. **Output/формат** (`compress-png`, `reduce-to-size-png`; пересекается с
`convert-png-to-jpg`/`webp`) — по `backlog.md` №7 формат переезжает в
кнопку Download, а не в отдельный инструмент. Перед переводом решить:
переводим как инструменты с quality-параметром или дожидаемся достройки
download-формата.
4. **`mix-colors` / `sort-colors`** — нужен список значений (цвета/веса или
источник). В схеме нет list-kind: либо новый kind, либо фиксированное
число слотов, либо отложить до отдельной задачи про списки.
## Известные долги перед переездом (завести tasks и закрыть)
По AGENTS.md техдолг «чинится только по заведённым tasks, не игнорируется»:
`lint:all` (~335 design-tokens в kit), неиспользуемые токены preview.css,
a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающих зелёной
проверке до/после переезда: svelte-check error `ToolCard.svelte:19:36` и фейл
`palette.test.ts` (ENOENT `src/app.css`). После их закрытия целевое состояние
проверок перед переездом: `pnpm --dir web exec svelte-check` → 0 errors,
`pnpm --dir web test` → зелёный, `pnpm --dir web lint:all` → только задокумент.
ированный остаток (или 0).
## Шаги перевода остатка (порядок)
1. **Простые пачки** (основной объём, 1 инструмент = 1 диф, паттерн Фазы 2):
color (18) → filters (3) → geometry простое → alpha (кроме watermark-image).
2. **Analyze-маски и вердикты** (12): переиспользование предикатов
`core/masks` в новом `registry-new/analyze.ts`; `show-color-pixels` — аналог
переведённого `extract-color-from-png` (уже в registry-new); text-вердикты
(png-info, png-is-grayscale, png-file-size, png-is-transparent,
png-orientation, verify-is-png) — вместе с text-source-механикой (п. 5).
3. **Generate-палитры** (8 без mix/sort): генераторы с палитрой (как
`color-spectrum`/`step-colors`, кнопка Generate).
4. **Raw-пакеты** (watermark-tile): font-style + угол + dimension(+),
сверить с `add-text` (переведён).
5. **Особые случаи** — отдельным переговоренным решением (overlay для
watermark-image, text-source, output-формат, списки для mix/sort).
6. После категории — `schema.layout` для сгруппированных инструментов
(шаг 32-33 паттерн) и ручная проверка в `/preview`.
## Переезд в `old/` (шаг 37 основного плана)
> Скоп-сдвиг: «Scoped-пути двигаются вместе с папками» — обновляется
> `eslint.config.js` (isolation-паттерны и design-tokens glob), не код правила.
1. **Что переезжает:** `lib/registry.ts`, `lib/registry/**`,
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
`lib/components/**` (кроме `kit/`) → папки `old/` (например
`lib/old/registry …`, `lib/old/tools`, `lib/old/components`) — то есть всё
старое в одном поддереве. `routes/(old)/` остаётся как есть (уже отделено).
2. **Обновить конфиг плагина** `isolation/no-mixed-imports`: старые glob-ы
`lib/registry/**…` заменить на `lib/old/**` (+ `lib/old/components/**` и
т.п.), «новое» и «общее» не меняются. Проверить, что правило по-прежнему
разделяет (тест фолс-позитивов: временный импорт old↔new ловится).
3. **Импорты внутри старого кода** — относительные пути остаются рабочими
(папки двигаются целиком); поправить только то, что ссылалось на `$lib/...`
в другом стиле. Старые тесты (`registry.test.ts` и др.) переезжают вместе с
исходниками и продолжают проходить.
4. **Гейты переезда:** `svelte-check` 0 errors, `pnpm --dir web test` зелёный,
`prettier --check .` чистый, `lint:all` = задокументированный остаток,
обе ветки открываются (старая `/`, новая `/preview`).
## Проверка и сбор отзывов после переезда
1. **Регресс-чеклист по каталогу** — пройти все инструменты в `/preview`:
дефолты совпадают со старыми, валидация/клампы поведения не меняют,
результат эквивалентен старому UI (сверить на одинаковых входах).
2. **Тяжёлые кейсы руками:** генераторы, маски, font-style/plate/gradient на
всем наборе, watermark-image (после overlay), text-source, output-формат.
3. **Сбор фидбека** — отдельные задачи на каждый найденный фикс; чеклист
зафиксировать в этом документе по мере находок (или ссылкой на tasks).
4. Сверка каталога с `tools-map.md` (покрытие/дедупликация
`convert-png-to-jpg`/`webp`), при необходимости — обновить cards/мета.
5. **Решение об удалении старого** — после завершения ревью и фидбека,
отдельным шагом: тогда чистятся `lib/old/**`, `routes/(old)/**`,
`old.css`, `exports`, изоляционный плагин (glob-ы больше не нужны) и
открываются «правило копий»-дубли.
## Как ревьюить каждый шаг (гайды)
- Диф на **один инструмент** (или фундамент без инструментов) < ~500 строк.
- Старый `params`/`run`/`defaultParams`/`sanitizeParams` не изменены;
в `registry-new` копия логики + `schema` (дефолты равны старым).
- Старый UI и старые тесты продолжают работать: `pnpm --dir web test`,
`pnpm --dir web exec svelte-check`.
- Новый инструмент проверяется **руками в `/preview`** end-to-end (применение +
сброс дефолтов + смена значений).
- После переезда каждый move-шаг гоняет 4 гейта (см. выше).
## Оценка трудозатрат
| Часть | Сложность | Оценка |
| ---------------------------------------------------------------- | --------------- | --------------------- |
| Простые инструменты (color/filters/geometry/alpha без watermark) | Низкая-Средняя | ~5-8ч (≈47 шт) |
| Analyze-маски (12, часть в п.5 с text-source) | Средняя | ~2ч |
| Generate-палитры (8) | Средняя | ~2-3ч |
| watermark-tile (text) | Средняя | ~1ч |
| Особые случаи (overlay/text-source/format/списки) | Средняя-Высокая | ~4-6ч + ревью решения |
| Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч |
| Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч |
| Проверка/фидбек + правки | Зависит | ~3-6ч |
| **Итого** | | **~21-34ч** по фазам |
> Оценки ориентировочные; особые случаи — с ревью решения, объём финальных
> правок по фидбеку не предсказуем.
+33
View File
@@ -0,0 +1,33 @@
/**
* Local ESLint plugin "isolation".
*
* Guarantees full isolation between the old UI branch and the new (preview)
* branch. Unlike no-restricted-imports (which matches the literal import
* specifier string only), these rules RESOLVE the specifier to a real file
* (supporting both `$lib/...` aliases and relative `./`/`../` paths) and then
* classify both sides by their actual location on disk.
*
* Why this is needed:
* - old code imports new things via RELATIVE paths (e.g. `../registry-schema`),
* which `$lib/...` glob patterns cannot catch;
* - the same target can be written many ways (`$lib/x`, `../x`, `../../x`),
* so enumerating literal strings is fragile.
*
* Configuration (per rule options):
* - `root`: path from the lint cwd to the source root (default "src");
* - `alias`: mapping from import aliases to dirs relative to root
* (default { "$lib": "lib" });
* - `old`, `new`: glob patterns (gitignore-style, `!` = negation) for the two
* branches. Files matched by neither are "shared" and unrestricted.
*/
import noMixedImports from "./no-mixed-imports.js";
export default {
meta: {
name: "isolation",
version: "1.0.0",
},
rules: {
"no-mixed-imports": noMixedImports,
},
};
@@ -0,0 +1,201 @@
/**
* Isolation rule: forbid mixing the "old" and the "new" (preview) branches.
*
* The rule RESOLVES every import specifier to a real file (handles both the
* `$lib/...` alias and relative `./` / `../` paths), classifies the source
* file and each import target by their actual location, and reports whenever an
* "old" file imports a "new" one or vice versa. Files that match neither side
* are "shared" (core/, i18n/, theme, assets, tests, ...) and can import freely.
*
* Options (object, all optional):
* - root: path from lint cwd to the source dir (default "src");
* - alias: { $lib: "lib" } — alias -> dir relative to root;
* - old, new: gitignore-style glob lists for the two branches. Both default
* to the current layout so that moving a module between branches is a
* one-line config change (e.g. everything old will live under `old/`).
*/
import fs from "node:fs";
import path from "node:path";
const DEFAULT_OLD = [
"routes/(old)/**",
"lib/registry/**",
"lib/registry.ts",
"lib/registry-helpers.ts",
"lib/categories.ts",
"lib/tools/**",
"lib/components/**",
"!lib/components/kit/**",
];
const DEFAULT_NEW = [
"routes/preview/**",
"lib/components/kit/**",
"lib/preview/**",
"lib/registry-new/**",
"lib/registry-schema.ts",
"lib/registry-schema.test.ts",
];
/** Escape everything except glob metacharacters, then handle **, *, ?. */
function globToRegExp(glob) {
let out = "^";
for (let i = 0; i < glob.length; i++) {
const ch = glob[i];
if (ch === "*" && glob[i + 1] === "*") {
out += ".*";
i++;
} else if (ch === "*") {
out += "[^/]*";
} else if (ch === "?") {
out += "[^/]";
} else if ("\\^$.|?*+()[]{}".includes(ch)) {
out += `\\${ch}`;
} else {
out += ch;
}
}
return new RegExp(out + "$");
}
/** gitignore-style: last-match-wins, `!` negates earlier positives. */
function matchesGlobList(patterns, rel) {
let included = false;
for (const raw of patterns) {
const negated = raw.startsWith("!");
const pattern = negated ? raw.slice(1) : raw;
if (globToRegExp(pattern).test(rel)) {
if (negated) return false;
included = true;
}
}
return included;
}
/** First existing candidate when resolving a bare path (no extension known). */
function firstExisting(base) {
const candidates = [
base,
`${base}.ts`,
`${base}.svelte`,
`${base}.svelte.ts`,
`${base}/index.ts`,
`${base}/index.svelte`,
];
for (const c of candidates) {
try {
if (fs.statSync(c).isFile()) return c;
} catch {
// keep trying
}
}
return null;
}
function toPortable(p) {
return p.replace(/\\/g, "/");
}
export default {
meta: {
type: "problem",
docs: {
description:
"Forbid imports between the old UI branch and the new (preview) branch.",
category: "Best Practices",
},
schema: [
{
type: "object",
properties: {
root: { type: "string" },
alias: { type: "object", additionalProperties: { type: "string" } },
old: { type: "array", items: { type: "string" } },
new: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
],
messages: {
noMixed:
'Isolation violation: "{{side}}" file imports "{{target}}" ({{targetRel}}).',
},
},
create(context) {
const options = context.options[0] || {};
const cwd = context.cwd;
const srcRoot = path.resolve(cwd, options.root || "src");
const alias = options.alias || { $lib: "lib" };
const oldPatterns = options.old || DEFAULT_OLD;
const newPatterns = options.new || DEFAULT_NEW;
/** Classify a path (already absolute or portable rel to srcRoot). */
function classify(absOrRel, relativeToRoot) {
const rel = relativeToRoot
? absOrRel
: toPortable(path.relative(srcRoot, absOrRel));
// new wins over old if a path is matched by both (e.g. components/kit
// is != lib/components/** via negation, but keep precedence safe).
if (matchesGlobList(newPatterns, rel)) return "new";
if (matchesGlobList(oldPatterns, rel)) return "old";
return "shared";
}
/** Resolve a specifier to a portable rel path from srcRoot, or null. */
function resolveToRel(spec, currentDir) {
if (spec.startsWith("$")) {
const dot = spec.indexOf("/");
const mapKey = spec.slice(0, dot === -1 ? spec.length : dot);
const dir = alias[mapKey];
if (!dir) return null;
const rest = dot === -1 ? "" : spec.slice(dot + 1);
const base = path.resolve(srcRoot, dir, rest);
const found = firstExisting(base);
if (!found) return null;
return toPortable(path.relative(srcRoot, found));
}
if (spec.startsWith("./") || spec.startsWith("../")) {
const base = path.resolve(currentDir, spec);
const found = firstExisting(base);
if (!found) return null;
const rel = path.relative(srcRoot, found);
if (rel.startsWith("..")) return null; // outside srcRoot
return toPortable(rel);
}
return null; // package import (svelte, vitest, @lucide, ...)
}
const filename = context.filename || context.physicalFilename;
const currentAbs = path.resolve(filename);
const currentRel = toPortable(path.relative(srcRoot, currentAbs));
const currentSide = classify(currentRel, true);
const currentDir = path.dirname(currentAbs);
if (currentSide === "shared") return {};
function checkSource(node) {
const spec = node.source?.value;
if (typeof spec !== "string") return;
const targetRel = resolveToRel(spec, currentDir);
if (!targetRel) return;
const targetSide = classify(targetRel, true);
if (
(currentSide === "old" && targetSide === "new") ||
(currentSide === "new" && targetSide === "old")
) {
context.report({
node,
messageId: "noMixed",
data: { side: currentSide, target: targetSide, targetRel },
});
}
}
return {
ImportDeclaration: checkSource,
ImportExpression: checkSource,
ExportNamedDeclaration: checkSource,
ExportAllDeclaration: checkSource,
};
},
};
+15
View File
@@ -2,6 +2,7 @@ import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import designTokens from "./eslint-plugins/index.js";
import isolationPlugin from "./eslint-plugins/isolation/index.js";
import globals from "globals";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
@@ -114,6 +115,20 @@ export default tseslint.config(
"prefer-const": "off",
},
},
// ===== Изоляция старого UI (old) и нового preview =====================
// Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5).
// Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному
// пути (и $lib, и относительные) и ругается на old→new / new→old.
// Общее (core/, i18n/, theme) разрешено обоим.
{
files: ["**/*.{ts,svelte}"],
plugins: {
isolation: isolationPlugin,
},
rules: {
"isolation/no-mixed-imports": "error",
},
},
// prettier — последним, чтобы гасить форматирующие правила из recommended.
prettier,
);
+78 -8
View File
@@ -14,6 +14,10 @@
import SelectControl from "./fields/schema/SelectControl.svelte";
import TextControl from "./fields/schema/TextControl.svelte";
import OffsetControl from "./fields/schema/OffsetControl.svelte";
import PositionControl from "./fields/schema/PositionControl.svelte";
import FontStyleControl from "./fields/schema/FontStyleControl.svelte";
import PlateControl from "./fields/schema/PlateControl.svelte";
import GradientControl from "./fields/schema/GradientControl.svelte";
interface FieldControlProps {
label: string;
@@ -32,6 +36,10 @@
text: TextControl,
dimension: DimensionField,
offset: OffsetControl,
position9: PositionControl,
"font-style": FontStyleControl,
plate: PlateControl,
gradient: GradientControl,
};
interface Props {
@@ -48,16 +56,61 @@
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
interface LayoutGroup {
key: string;
title?: string;
cols: number;
fields: string[];
}
const layoutGroups = $derived.by((): LayoutGroup[] => {
const all = Object.keys(schema.fields);
const groups = schema.layout?.groups ?? [];
const used: Record<string, true> = {};
const named = groups
.map((g, gi) => {
const fields = g.fields.filter((f) => {
if (used[f] || !(f in schema.fields)) return false;
used[f] = true;
return true;
});
return {
key: `${g.title ?? "group"}-${gi}`,
title: g.title,
cols: Math.max(1, Math.trunc(g.cols ?? 1)),
fields,
} satisfies LayoutGroup;
})
.filter((g) => g.fields.length > 0);
const rest = all.filter((f) => !used[f]);
if (rest.length > 0) {
named.push({ key: "__default", title: undefined, cols: 1, fields: rest });
}
return named;
});
</script>
{#each Object.entries(schema.fields) as [id, field] (id)}
{@const Control = FIELDS[field.spec.kind]}
<Control
label={labelOf(id)}
value={values[id]}
spec={field.spec}
onchange={(v) => onchange(id, v)}
/>
{#each layoutGroups as group (group.key)}
{#if group.title}
<span class="group-title">{group.title}</span>
{/if}
<div
class="group-fields"
style:grid-template-columns={group.cols > 1
? `repeat(${group.cols}, minmax(0, 1fr))`
: undefined}
>
{#each group.fields as id (id)}
{@const Control = FIELDS[schema.fields[id].spec.kind]}
<Control
label={labelOf(id)}
value={values[id]}
spec={schema.fields[id].spec}
onchange={(v) => onchange(id, v)}
/>
{/each}
</div>
{/each}
<div class="panel-foot">
@@ -68,6 +121,23 @@
</div>
<style>
.group-title {
display: block;
margin-top: var(--space-l);
margin-bottom: var(--space-s);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.group-title:first-child {
margin-top: 0;
}
.group-fields {
display: grid;
grid-template-columns: 1fr;
column-gap: var(--space-l);
}
.panel-foot {
display: flex;
justify-content: space-between;
@@ -0,0 +1,107 @@
<script lang="ts">
interface Preset {
value: number;
label: string;
}
interface Props {
label?: string;
value: number;
min?: number;
max?: number;
step?: number;
presets?: Preset[];
onchange: (angle: number) => void;
}
let {
label = "Angle",
value,
min = 0,
max = 360,
step = 1,
presets = [
{ value: 0, label: "0°" },
{ value: 90, label: "90°" },
{ value: 180, label: "180°" },
{ value: 270, label: "270°" },
],
onchange,
}: Props = $props();
</script>
<div class="angle-control">
{#if label}
<span class="angle-label">{label}</span>
{/if}
<div class="angle-row">
<input
type="range"
{min}
{max}
{step}
{value}
oninput={(e) => onchange(Number((e.target as HTMLInputElement).value))}
/>
<output>{value}°</output>
</div>
<div class="angle-presets">
{#each presets as preset (preset.value)}
<button
class:active={preset.value === value}
onclick={() => onchange(preset.value)}>{preset.label}</button
>
{/each}
</div>
</div>
<style>
.angle-control {
display: grid;
gap: var(--space-m);
}
.angle-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.angle-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-m);
}
.angle-row input[type="range"] {
width: 100%;
accent-color: var(--color-main);
}
.angle-row output {
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.angle-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-m);
}
.angle-presets button {
padding: var(--space-m) 0;
color: var(--color-text-muted);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
cursor: pointer;
}
.angle-presets button:hover {
color: var(--color-text);
border-color: var(--color-main);
}
.angle-presets button.active {
color: var(--color-main);
border-color: var(--color-main);
}
</style>
@@ -0,0 +1,162 @@
<script lang="ts">
import type { TextFont } from "$lib/core/domText";
import type {
FieldSpec,
FontStyle,
FontStyleSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: FontStyle) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as FontStyleSpec);
const current = $derived.by(() => {
const v = value as Partial<FontStyle> | undefined;
return {
font:
v?.font === "sans" || v?.font === "serif" || v?.font === "mono"
? v.font
: sp.font,
size:
typeof v?.size === "number" && Number.isFinite(v.size)
? v.size
: sp.size,
bold: typeof v?.bold === "boolean" ? v.bold : sp.bold,
color: typeof v?.color === "string" ? v.color : sp.color,
};
});
function set<K extends keyof FontStyle>(key: K, val: FontStyle[K]) {
onchange?.({ ...current, [key]: val });
}
const FONTS: { value: TextFont; label: string }[] = [
{ value: "sans", label: "Sans" },
{ value: "serif", label: "Serif" },
{ value: "mono", label: "Mono" },
];
</script>
<div class="control font-style-control">
<span class="fs-label">{label}</span>
<div class="fs-grid">
<label class="fs-cell">
<span class="fs-sub">Font</span>
<select
class="fs-select"
value={current.font}
oninput={(e) =>
set("font", (e.target as HTMLSelectElement).value as TextFont)}
>
{#each FONTS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
<label class="fs-cell">
<span class="fs-sub">Size</span>
<input
class="fs-input"
type="number"
min={sp.min}
max={sp.max}
value={current.size}
oninput={(e) =>
set("size", Number((e.target as HTMLInputElement).value))}
/>
</label>
<label class="fs-cell">
<span class="fs-sub">Color</span>
<span class="fs-color">
<span class="swatch" style="background:{current.color}"></span>
<input
class="fs-color-input"
type="color"
value={current.color}
oninput={(e) => set("color", (e.target as HTMLInputElement).value)}
/>
</span>
</label>
<label class="fs-cell fs-bold">
<span class="fs-sub">Bold</span>
<input
type="checkbox"
checked={current.bold}
onchange={(e) => set("bold", (e.target as HTMLInputElement).checked)}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.fs-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.fs-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.fs-cell {
display: grid;
gap: var(--space-m);
}
.fs-sub {
font-size: var(--font-size-s);
}
.fs-select,
.fs-input,
.fs-color-input {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
.fs-color {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.fs-color-input {
height: var(--space-xxl);
padding: 0;
cursor: pointer;
}
.fs-bold {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
}
.fs-bold input[type="checkbox"] {
width: var(--space-xl);
height: var(--space-xl);
accent-color: var(--color-main);
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import AngleControl from "./AngleControl.svelte";
import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Gradient) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as GradientSpec);
const current = $derived.by(() => {
const v = value as Partial<Gradient> | undefined;
return {
from: typeof v?.from === "string" ? v.from : sp.from,
to: typeof v?.to === "string" ? v.to : sp.to,
angle:
typeof v?.angle === "number" && Number.isFinite(v.angle)
? v.angle
: sp.angle,
};
});
function set<K extends keyof Gradient>(key: K, val: Gradient[K]) {
onchange?.({ ...current, [key]: val });
}
</script>
<div class="control gradient-control">
<span class="gradient-label">{label}</span>
<div class="gradient-colors">
<label class="color-field">
<span>From</span>
<span class="swatch" style="background:{current.from}"></span>
<input
type="color"
value={current.from}
oninput={(e) => set("from", (e.target as HTMLInputElement).value)}
/>
</label>
<label class="color-field">
<span>To</span>
<span class="swatch" style="background:{current.to}"></span>
<input
type="color"
value={current.to}
oninput={(e) => set("to", (e.target as HTMLInputElement).value)}
/>
</label>
</div>
<AngleControl value={current.angle} onchange={(a) => set("angle", a)} />
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.gradient-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.gradient-colors {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m) var(--space-l);
}
.color-field {
display: grid;
grid-template-columns: auto var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.color-field input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
}
</style>
@@ -0,0 +1,139 @@
<script lang="ts">
import type { FieldSpec, Plate, PlateSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Plate) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as PlateSpec);
const current = $derived.by(() => {
const v = value as Partial<Plate> | undefined;
return {
enabled: typeof v?.enabled === "boolean" ? v.enabled : sp.enabled,
color: typeof v?.color === "string" ? v.color : sp.color,
opacity:
typeof v?.opacity === "number" && Number.isFinite(v.opacity)
? v.opacity
: sp.opacity,
};
});
function set<K extends keyof Plate>(key: K, val: Plate[K]) {
onchange?.({ ...current, [key]: val });
}
</script>
<div class="control plate-control">
<span class="plate-label">{label}</span>
<div class="plate-row {current.enabled ? '' : 'off'}">
<label class="plate-enabled">
<span>Backing plate</span>
<input
type="checkbox"
checked={current.enabled}
onchange={(e) => set("enabled", (e.target as HTMLInputElement).checked)}
/>
</label>
<label class="plate-color">
<span class="swatch" style="background:{current.color}"></span>
<input
type="color"
value={current.color}
disabled={!current.enabled}
oninput={(e) => set("color", (e.target as HTMLInputElement).value)}
/>
</label>
<label class="plate-opacity">
<span>
Opacity
<output>{current.opacity}%</output>
</span>
<input
type="range"
min="0"
max="100"
step="5"
value={current.opacity}
disabled={!current.enabled}
oninput={(e) =>
set("opacity", Number((e.target as HTMLInputElement).value))}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.plate-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.plate-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m) var(--space-l);
}
.plate-row.off {
opacity: 0.55;
}
.plate-enabled {
display: flex;
justify-content: space-between;
align-items: center;
grid-column: 1 / -1;
}
.plate-enabled input[type="checkbox"] {
width: var(--space-xl);
height: var(--space-xl);
accent-color: var(--color-main);
}
.plate-color {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.plate-color input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
}
.plate-opacity {
display: grid;
gap: var(--space-m);
}
.plate-opacity > span {
display: flex;
justify-content: space-between;
}
.plate-opacity output {
color: var(--color-text);
}
.plate-opacity input[type="range"] {
width: 100%;
accent-color: var(--color-main);
color: var(--color-text);
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Position9 } from "$lib/core/textdraw";
import {
POSITION9_VALUES,
type FieldSpec,
type Position9Spec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Position9) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as Position9Spec);
const current = $derived(
typeof value === "string" &&
(POSITION9_VALUES as readonly string[]).includes(value)
? (value as Position9)
: sp.default,
);
</script>
<div class="control position-control">
<span class="position-label">{label}</span>
<div class="grid">
{#each POSITION9_VALUES as position (position)}
<button
type="button"
class="cell {current === position ? 'active' : ''}"
aria-label={position}
aria-pressed={current === position}
onclick={() => onchange?.(position)}
>
<span class="dot"></span>
</button>
{/each}
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.position-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.grid {
display: inline-grid;
grid-template-columns: repeat(3, var(--space-xl));
gap: var(--space-s);
}
.cell {
display: grid;
place-items: center;
width: var(--space-xl);
height: var(--space-xl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
cursor: pointer;
}
.cell:hover {
border-color: var(--color-main);
}
.dot {
width: var(--space-s);
height: var(--space-s);
border-radius: 50%;
background: var(--color-border);
}
.active {
border-color: var(--color-main);
background: var(--color-main);
}
.active .dot {
background: var(--color-text);
}
</style>
+184
View File
@@ -0,0 +1,184 @@
import {
AppWindow,
Binary,
Blend,
CalendarDays,
ClipboardPaste,
Circle,
Contrast,
Crop,
Crosshair,
Dices,
Droplet,
Droplets,
Eye,
FileImage,
FileOutput,
FilePlus2,
Film,
FlipHorizontal2,
Focus,
Frame,
Grid3x3,
Hammer,
Hash,
Image as ImageIcon,
Info,
Layers,
Link2,
Maximize2,
Minimize2,
Moon,
PaintBucket,
Palette,
Rainbow,
Repeat2,
RotateCw,
Ruler,
Scaling,
Scan,
Scissors,
SearchCheck,
FaceSlightlySmiling,
Square,
Stamp,
Star,
Sun,
Type,
WavesHorizontal,
ZoomIn,
ZoomOut,
} from "@lucide/svelte";
export const TOOL_ICONS: Record<string, typeof AppWindow> = {
"resize-png": Scaling,
"crop-png": Crop,
"rotate-png": RotateCw,
"flip-png": FlipHorizontal2,
"grayscale-png": Contrast,
"invert-colors-png": Blend,
"adjust-brightness-contrast-png": Sun,
"convert-png-to-jpg": FileImage,
"convert-png-to-webp": FileImage,
"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-base64": Binary,
"base64-to-png": ClipboardPaste,
"png-to-data-uri": Link2,
"data-uri-to-png": ClipboardPaste,
"png-to-hex": Hash,
"hex-to-png": Hash,
"change-png-opacity": Droplet,
"sepia-png": Palette,
"change-png-hue": Rainbow,
"extract-channel-png": Layers,
"swap-channels-png": Repeat2,
"black-and-white-png": Contrast,
"posterize-png": Layers,
"two-colors-png": Blend,
"invert-alpha-png": Droplets,
"remove-alpha-channel-png": Square,
"set-alpha-channel-png": Droplet,
"extract-alpha-mask-png": Layers,
"round-corners-png": Frame,
"add-padding-png": Maximize2,
"add-border-png": Frame,
"fit-on-background-png": ImageIcon,
"tile-png": Grid3x3,
"center-by-alpha-png": Crosshair,
"create-empty-png": FilePlus2,
"single-color-png": PaintBucket,
"random-noise-png": Dices,
"linear-gradient-png": Sun,
"png-is-grayscale": SearchCheck,
"png-is-transparent": Eye,
"png-orientation": Ruler,
"blur-png": Droplets,
"sharpen-png": Focus,
"remove-background-png": Scissors,
"add-stroke-png": Square,
"add-text-png": Type,
"date-stamp-png": CalendarDays,
"png-to-hsl": Blend,
"png-to-hsv": Droplet,
"png-to-hsi": Focus,
"png-to-cmyk": PaintBucket,
"png-to-ycbcr": Layers,
"png-to-lab": Ruler,
"show-transparent-png": Eye,
"show-grayscale-pixels-png": Contrast,
"show-color-pixels-png": Rainbow,
"light-pixel-mask-png": Sun,
"dark-pixel-mask-png": Moon,
"unique-color-mask-png": Dices,
"extract-color-from-png": Focus,
"quantize-png": Contrast,
"decrease-color-count-png": Scaling,
"custom-palette-png": Palette,
"dithering-png": Dices,
"feather-edges-png": Droplets,
"clean-edges-png": Scissors,
"pixelate-png": Grid3x3,
"randomize-pixels-png": Dices,
"add-noise-png": Hash,
"silhouette-png": Contrast,
"trim-empty-space-png": Crop,
"change-canvas-size-png": Frame,
"change-aspect-ratio-png": Scaling,
"swap-orientation-png": RotateCw,
"symmetric-copy-png": FlipHorizontal2,
"compress-png": Minimize2,
"reduce-to-size-png": Scaling,
"png-file-size": Info,
"png-to-bytes": Binary,
"bytes-to-png": Binary,
"png-to-rgb-values": Hash,
"rgb-values-to-png": Hash,
"verify-is-png": SearchCheck,
"text-to-png": Type,
"emoji-to-png": FaceSlightlySmiling,
"placeholder-png": Frame,
"color-spectrum-png": Rainbow,
"random-colors-png": Dices,
"draw-grid-png": Grid3x3,
"circle-mask-png": Circle,
"square-mask-png": Square,
"star-mask-png": Star,
"wavy-mask-png": WavesHorizontal,
"watermark-tile-png": Stamp,
"watermark-image-png": FileImage,
"color-wheel-png": Rainbow,
"complementary-png": Contrast,
"triadic-png": Hash,
"tetradic-png": Grid3x3,
"analogous-png": Blend,
"monochromatic-png": Droplets,
"shades-png": Sun,
"mix-colors-png": PaintBucket,
"blend-two-png": Layers,
"step-colors-png": Scaling,
"sort-colors-png": SearchCheck,
"find-contour-png": Scan,
"make-thicker-png": ZoomIn,
"make-thinner-png": ZoomOut,
"harden-alpha-png": Sun,
"despeckle-alpha-png": SearchCheck,
"close-holes-png": Hammer,
"skew-png": Repeat2,
"rotate-free-png": RotateCw,
"zoom-png": ZoomIn,
"shift-png": Crosshair,
"vignette-png": Sun,
"jpeg-artifacts-png": Layers,
"gamma-png": Contrast,
"auto-contrast-png": Contrast,
"temperature-png": Sun,
"tint-png": Droplet,
"svg-to-png": FileImage,
};
+75 -23
View File
@@ -77,10 +77,20 @@ interface CircleMaskParams {
offset: Offset;
}
const circleMaskSchema = toolSchema<CircleMaskParams>({
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const circleMaskSchema = toolSchema<CircleMaskParams>(
{
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
},
{
layout: {
groups: [
{ title: "Shape", fields: ["size"] },
{ title: "Position", fields: ["offset"] },
],
},
},
);
const circleMask: ToolEntry<CircleMaskParams> = {
id: "circle-mask-png",
@@ -104,11 +114,25 @@ interface SquareMaskParams {
offset: Offset;
}
const squareMaskSchema = toolSchema<SquareMaskParams>({
widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
heightPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const squareMaskSchema = toolSchema<SquareMaskParams>(
{
widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
heightPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
},
{
layout: {
groups: [
{
title: "Shape",
cols: 2,
fields: ["widthPct", "heightPct"],
},
{ title: "Position", fields: ["offset"] },
],
},
},
);
const squareMask: ToolEntry<SquareMaskParams> = {
id: "square-mask-png",
@@ -134,13 +158,27 @@ interface StarMaskParams {
offset: Offset;
}
const starMaskSchema = toolSchema<StarMaskParams>({
points: field.slider({ min: 3, max: 12, step: 1, default: 5 }),
innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }),
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const starMaskSchema = toolSchema<StarMaskParams>(
{
points: field.slider({ min: 3, max: 12, step: 1, default: 5 }),
innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }),
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
},
{
layout: {
groups: [
{
title: "Shape",
cols: 2,
fields: ["points", "innerRadius", "size", "rotation"],
},
{ title: "Position", fields: ["offset"] },
],
},
},
);
const starMask: ToolEntry<StarMaskParams> = {
id: "star-mask-png",
@@ -166,13 +204,27 @@ interface WavyMaskParams {
offset: Offset;
}
const wavyMaskSchema = toolSchema<WavyMaskParams>({
size: field.slider({ min: 20, max: 100, step: 1, default: 90 }),
amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }),
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const wavyMaskSchema = toolSchema<WavyMaskParams>(
{
size: field.slider({ min: 20, max: 100, step: 1, default: 90 }),
amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }),
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
},
{
layout: {
groups: [
{
title: "Shape",
cols: 2,
fields: ["size", "amplitude", "waves", "phase"],
},
{ title: "Position", fields: ["offset"] },
],
},
},
);
const wavyMask: ToolEntry<WavyMaskParams> = {
id: "wavy-mask-png",
+32 -15
View File
@@ -45,10 +45,17 @@ interface RandomizePixelsParams {
seed: number;
}
export const randomizePixelsSchema = toolSchema<RandomizePixelsParams>({
blockSize: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
seed: field.number({ min: 0, max: 999999, step: 1, default: 42 }),
});
export const randomizePixelsSchema = toolSchema<RandomizePixelsParams>(
{
blockSize: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
seed: field.number({ min: 0, max: 999999, step: 1, default: 42 }),
},
{
layout: {
groups: [{ title: "Blocks", cols: 2, fields: ["blockSize", "seed"] }],
},
},
);
const randomizePixels: ToolEntry<RandomizePixelsParams> = {
id: "randomize-pixels-png",
@@ -66,17 +73,27 @@ interface AddNoiseParams {
seed: number;
}
export const addNoiseSchema = toolSchema<AddNoiseParams>({
amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }),
mode: field.select({
default: "mono",
options: [
{ value: "mono", label: "Monochrome grain" },
{ value: "color", label: "Color noise" },
],
}),
seed: field.number({ min: 0, max: 999999, step: 1, default: 1234 }),
});
export const addNoiseSchema = toolSchema<AddNoiseParams>(
{
amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }),
mode: field.select({
default: "mono",
options: [
{ value: "mono", label: "Monochrome grain" },
{ value: "color", label: "Color noise" },
],
}),
seed: field.number({ min: 0, max: 999999, step: 1, default: 1234 }),
},
{
layout: {
groups: [
{ title: "Noise", cols: 2, fields: ["amount", "mode"] },
{ title: "Seed", fields: ["seed"] },
],
},
},
);
const addNoiseTool: ToolEntry<AddNoiseParams> = {
id: "add-noise-png",
+235 -64
View File
@@ -1,7 +1,8 @@
import { renderTextToImage } from "../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { gradientImage, noiseImage, solidImage } from "../core/generate";
import { noiseImage, solidImage } from "../core/generate";
import { changeCanvasSize } from "../core/geometry";
import type { PixelImage } from "../core/types";
import {
hexToRgb,
renderBlend,
@@ -13,6 +14,8 @@ import {
toolSchema,
type ColorPair,
type Dimension,
type FontStyle,
type Gradient,
} from "../registry-schema";
import type { ToolEntry } from "./types";
@@ -21,17 +24,66 @@ function rgba(hex: string): [number, number, number, number] {
return [r, g, b, 255];
}
/**
* Линейный градиент по произвольному углу. 0° — слева направо, 90° — сверху
* вниз (рост угла по часовой, ось Y вниз). Угол задаёт направление оси
* градиента; t пикселя — нормализованная проекция на эту ось.
*/
function angleGradient(
width: number,
height: number,
fromRgba: [number, number, number, number],
toRgba: [number, number, number, number],
angle: number,
): PixelImage {
const rad = (angle * Math.PI) / 180;
const vx = Math.cos(rad);
const vy = Math.sin(rad);
// Минимум и максимум проекции на ось достигаются в противоположных углах.
const rightX = vx >= 0 ? width - 1 : 0;
const bottomY = vy >= 0 ? height - 1 : 0;
const projMax = rightX * vx + bottomY * vy;
const projMin = (width - 1 - rightX) * vx + (height - 1 - bottomY) * vy;
const span = projMax - projMin;
const out: PixelImage = {
width,
height,
data: new Uint8ClampedArray(width * height * 4),
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const t = span === 0 ? 0 : (x * vx + y * vy - projMin) / span;
const i = (y * width + x) * 4;
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
out.data[i + 2] = fromRgba[2] + (toRgba[2] - fromRgba[2]) * t;
out.data[i + 3] = fromRgba[3] + (toRgba[3] - fromRgba[3]) * t;
}
}
return out;
}
interface CreateEmptyParams {
size: Dimension;
transparent: boolean;
color: string;
}
export const createEmptySchema = toolSchema<CreateEmptyParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
transparent: field.checkbox({ default: true }),
color: field.color({ default: "#ffffff" }),
});
export const createEmptySchema = toolSchema<CreateEmptyParams>(
{
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
transparent: field.checkbox({ default: true }),
color: field.color({ default: "#ffffff" }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Fill", fields: ["transparent", "color"] },
],
},
},
);
const createEmpty: ToolEntry<CreateEmptyParams> = {
id: "create-empty-png",
@@ -99,33 +151,41 @@ const randomNoise: ToolEntry<RandomNoiseParams> = {
interface LinearGradientParams {
size: Dimension;
pair: ColorPair;
direction: "horizontal" | "vertical";
gradient: Gradient;
}
export const linearGradientSchema = toolSchema<LinearGradientParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
direction: field.select({
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
}),
});
export const linearGradientSchema = toolSchema<LinearGradientParams>(
{
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
gradient: field.gradient({ from: "#000000", to: "#ffffff", angle: 0 }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Colors", fields: ["gradient"] },
],
},
},
);
const linearGradient: ToolEntry<LinearGradientParams> = {
id: "linear-gradient-png",
title: "Create gradient PNG",
description:
"Generates a smooth transition between two colors, horizontally or vertically.",
"Generates a smooth transition between two colors along a chosen angle.",
category: "generate",
schema: linearGradientSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return gradientImage(w, h, rgba(p.pair.from), rgba(p.pair.to), p.direction);
return angleGradient(
w,
h,
rgba(p.gradient.from),
rgba(p.gradient.to),
p.gradient.angle,
);
},
};
@@ -136,18 +196,28 @@ interface ColorSpectrumParams {
lightness: number;
}
export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>({
size: field.dimension({ min: 1, max: 5000, width: 1024, height: 128 }),
direction: field.select({
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
}),
saturation: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>(
{
size: field.dimension({ min: 1, max: 5000, width: 1024, height: 128 }),
direction: field.select({
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
}),
saturation: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Spectrum", fields: ["direction", "saturation", "lightness"] },
],
},
},
);
const colorSpectrumTool: ToolEntry<ColorSpectrumParams> = {
id: "color-spectrum-png",
@@ -169,11 +239,21 @@ interface RandomColorsParams {
seed: number;
}
export const randomColorsSchema = toolSchema<RandomColorsParams>({
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
blockSize: field.slider({ min: 4, max: 256, step: 2, default: 64 }),
seed: field.number({ min: 0, max: 999999999, step: 1, default: 7 }),
});
export const randomColorsSchema = toolSchema<RandomColorsParams>(
{
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
blockSize: field.slider({ min: 4, max: 256, step: 2, default: 64 }),
seed: field.number({ min: 0, max: 999999999, step: 1, default: 7 }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Random", cols: 2, fields: ["blockSize", "seed"] },
],
},
},
);
const randomColors: ToolEntry<RandomColorsParams> = {
id: "random-colors-png",
@@ -198,14 +278,28 @@ interface DrawGridParams {
transparentBg: boolean;
}
export const drawGridSchema = toolSchema<DrawGridParams>({
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
cols: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
rows: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
lineWidth: field.slider({ min: 1, max: 40, step: 1, default: 2 }),
color: field.color({ default: "#111318" }),
transparentBg: field.checkbox({ default: true }),
});
export const drawGridSchema = toolSchema<DrawGridParams>(
{
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
cols: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
rows: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
lineWidth: field.slider({ min: 1, max: 40, step: 1, default: 2 }),
color: field.color({ default: "#111318" }),
transparentBg: field.checkbox({ default: true }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{
title: "Grid",
cols: 2,
fields: ["cols", "rows", "lineWidth", "color", "transparentBg"],
},
],
},
},
);
const drawGridTool: ToolEntry<DrawGridParams> = {
id: "draw-grid-png",
@@ -236,12 +330,23 @@ interface PlaceholderParams {
showText: boolean;
}
export const placeholderSchema = toolSchema<PlaceholderParams>({
size: field.dimension({ min: 1, max: 5000, width: 800, height: 400 }),
backgroundColor: field.color({ default: "#dfe2e8" }),
color: field.color({ default: "#5c6470" }),
showText: field.checkbox({ default: true }),
});
export const placeholderSchema = toolSchema<PlaceholderParams>(
{
size: field.dimension({ min: 1, max: 5000, width: 800, height: 400 }),
backgroundColor: field.color({ default: "#dfe2e8" }),
color: field.color({ default: "#5c6470" }),
showText: field.checkbox({ default: true }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Colors", cols: 2, fields: ["backgroundColor", "color"] },
{ title: "Text", fields: ["showText"] },
],
},
},
);
const placeholder: ToolEntry<PlaceholderParams> = {
id: "placeholder-png",
@@ -297,18 +402,28 @@ interface StepColorsParams {
layout: "strip" | "grid";
}
export const stepColorsSchema = toolSchema<StepColorsParams>({
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }),
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
layout: field.select({
default: "grid",
options: [
{ value: "grid", label: "Grid" },
{ value: "strip", label: "Strip" },
],
}),
});
export const stepColorsSchema = toolSchema<StepColorsParams>(
{
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }),
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
layout: field.select({
default: "grid",
options: [
{ value: "grid", label: "Grid" },
{ value: "strip", label: "Strip" },
],
}),
},
{
layout: {
groups: [
{ title: "Colors", fields: ["pair"] },
{ title: "Output", cols: 2, fields: ["steps", "width", "layout"] },
],
},
},
);
const stepColorsTool: ToolEntry<StepColorsParams> = {
id: "step-colors-png",
@@ -324,6 +439,61 @@ const stepColorsTool: ToolEntry<StepColorsParams> = {
),
};
interface TextToPngParams {
text: string;
style: FontStyle;
transparentBg: boolean;
backgroundColor: string;
padding: number;
}
export const textToPngSchema = toolSchema<TextToPngParams>(
{
text: field.text({ default: "Hello!", placeholder: "Your text" }),
style: field.fontStyle({
min: 8,
max: 300,
size: 96,
font: "sans",
bold: true,
color: "#111318",
}),
transparentBg: field.checkbox({ default: false }),
backgroundColor: field.color({ default: "#ffffff" }),
padding: field.slider({ min: 0, max: 200, step: 2, default: 24 }),
},
{
layout: {
groups: [
{ title: "Text", fields: ["text", "style"] },
{ title: "Background", fields: ["transparentBg", "backgroundColor"] },
{ title: "Padding", fields: ["padding"] },
],
},
},
);
const textToPng: ToolEntry<TextToPngParams> = {
id: "text-to-png",
title: "Text to PNG",
description:
"Creates a PNG image from text: the canvas is sized to fit the label plus padding.",
category: "generate",
domOnly: true,
schema: textToPngSchema,
generate: (p) =>
renderTextToImage({
text: p.text,
fontSize: p.style.size,
font: p.style.font,
bold: p.style.bold,
color: p.style.color,
backgroundColor: p.backgroundColor,
transparentBg: p.transparentBg,
padding: p.padding,
}),
};
export const generateEntries = [
createEmpty,
singleColor,
@@ -335,4 +505,5 @@ export const generateEntries = [
placeholder,
blendTwo,
stepColorsTool,
textToPng,
];
+71 -31
View File
@@ -43,11 +43,21 @@ interface FitOnBackgroundParams {
color: string;
}
export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
transparent: field.checkbox({ default: false }),
color: field.color({ default: "#ffffff" }),
});
export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
{
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
transparent: field.checkbox({ default: false }),
color: field.color({ default: "#ffffff" }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Background", fields: ["transparent", "color"] },
],
},
},
);
const fitOnBackground: ToolEntry<FitOnBackgroundParams> = {
id: "fit-on-background-png",
@@ -80,23 +90,33 @@ interface ChangeCanvasSizeParams {
anchor: Anchor9;
}
export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
anchor: field.select({
default: "center",
options: [
{ value: "top-left", label: "Top left" },
{ value: "top-center", label: "Top center" },
{ value: "top-right", label: "Top right" },
{ value: "middle-left", label: "Middle left" },
{ value: "center", label: "Center" },
{ value: "middle-right", label: "Middle right" },
{ value: "bottom-left", label: "Bottom left" },
{ value: "bottom-center", label: "Bottom center" },
{ value: "bottom-right", label: "Bottom right" },
],
}),
});
export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>(
{
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
anchor: field.select({
default: "center",
options: [
{ value: "top-left", label: "Top left" },
{ value: "top-center", label: "Top center" },
{ value: "top-right", label: "Top right" },
{ value: "middle-left", label: "Middle left" },
{ value: "center", label: "Center" },
{ value: "middle-right", label: "Middle right" },
{ value: "bottom-left", label: "Bottom left" },
{ value: "bottom-center", label: "Bottom center" },
{ value: "bottom-right", label: "Bottom right" },
],
}),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Anchor", fields: ["anchor"] },
],
},
},
);
const changeCanvasSizeTool: ToolEntry<ChangeCanvasSizeParams> = {
id: "change-canvas-size-png",
@@ -119,10 +139,20 @@ interface ResizeParams {
keepAspect: boolean;
}
export const resizeSchema = toolSchema<ResizeParams>({
size: field.dimension({ min: 0, max: 20000, width: 0, height: 0 }),
keepAspect: field.checkbox({ default: true }),
});
export const resizeSchema = toolSchema<ResizeParams>(
{
size: field.dimension({ min: 0, max: 20000, width: 0, height: 0 }),
keepAspect: field.checkbox({ default: true }),
},
{
layout: {
groups: [
{ title: "Canvas", fields: ["size"] },
{ title: "Scaling", fields: ["keepAspect"] },
],
},
},
);
const resizeTool: ToolEntry<ResizeParams> = {
id: "resize-png",
@@ -159,11 +189,21 @@ interface CropParams {
size: Dimension;
}
export const cropSchema = toolSchema<CropParams>({
x: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
y: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
size: field.dimension({ min: 0, max: 100000, width: 0, height: 0 }),
});
export const cropSchema = toolSchema<CropParams>(
{
x: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
y: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
size: field.dimension({ min: 0, max: 100000, width: 0, height: 0 }),
},
{
layout: {
groups: [
{ title: "Offset", fields: ["x", "y"] },
{ title: "Crop area", fields: ["size"] },
],
},
},
);
const cropTool: ToolEntry<CropParams> = {
id: "crop-png",
+2
View File
@@ -6,6 +6,7 @@ import { analyzeEntries } from "./analyze";
import { filtersEntries } from "./filters";
import { colorEntries } from "./color";
import { generateEntries } from "./generate";
import { textEntries } from "./text";
export type { ToolEntry } from "./types";
@@ -17,6 +18,7 @@ export const TOOLS: ToolEntry[] = [
...filtersEntries,
...colorEntries,
...generateEntries,
...textEntries,
] as unknown as ToolEntry[];
export function getTool(id: string): ToolEntry | undefined {
+144 -2
View File
@@ -65,8 +65,11 @@ describe("registry-new (переведённые инструменты)", () =>
"linear-gradient-png",
{
size: { width: 40, height: 30 },
pair: { from: "#000000", to: "#ffffff" },
direction: "horizontal",
gradient: {
from: "#000000",
to: "#ffffff",
angle: 90,
},
},
],
[
@@ -235,6 +238,61 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.data[102]).toBe(255);
});
it("linear-gradient: дефолты gradient и направление по углу", async () => {
const tool = TOOLS.find((t) => t.id === "linear-gradient-png")!;
const d = defaultSchemaParams(tool.schema);
expect(d.gradient).toEqual({
from: "#000000",
to: "#ffffff",
angle: 0,
});
// 0° — слева направо: крайний левый пиксель = from, крайний правый = to
let img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 4, height: 1 },
gradient: { from: "#000000", to: "#ffffff", angle: 0 },
}),
);
expect(img.data[0]).toBe(0);
expect(img.data[12]).toBe(255);
// 90° — сверху вниз: верхний пиксель = from, нижний = to
img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 1, height: 4 },
gradient: { from: "#000000", to: "#ffffff", angle: 90 },
}),
);
expect(img.data[0]).toBe(0);
expect(img.data[12]).toBe(255);
// 180° — разворот: левый пиксель = to
img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 4, height: 1 },
gradient: { from: "#000000", to: "#ffffff", angle: 180 },
}),
);
expect(img.data[0]).toBe(255);
expect(img.data[12]).toBe(0);
});
it("sanitize чинит мусор в gradient и клампит angle в 0..360", () => {
const tool = TOOLS.find((t) => t.id === "linear-gradient-png")!;
const s = sanitizeSchemaParams(tool.schema, {
size: { width: 10, height: 10 },
gradient: {
from: "not-a-color",
to: "#00ff00",
angle: 720,
extra: 1,
},
});
expect(s.gradient).toEqual({
from: "#000000",
to: "#00ff00",
angle: 360,
});
});
it("sanitize чинит мусор в паре и клампит threshold", () => {
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
const s = sanitizeSchemaParams(tool.schema, {
@@ -304,6 +362,90 @@ describe("registry-new (переведённые инструменты)", () =>
});
expect(s.offset).toEqual({ x: 50, y: 0 });
});
it("add-text: дефолты position9, font-style, plate и текстовых полей", () => {
const tool = TOOLS.find((t) => t.id === "add-text-png")!;
const d = defaultSchemaParams(tool.schema);
expect(d.position).toBe("bottom-right");
expect(d.text).toBe("Hello!");
expect(d.plate).toEqual({
enabled: false,
color: "#000000",
opacity: 60,
});
expect(d.style).toEqual({
font: "sans",
size: 48,
bold: true,
color: "#ffffff",
});
});
it("sanitize чинит мусор в position9, font-style, plate и оставляет валидные значения", () => {
const tool = TOOLS.find((t) => t.id === "date-stamp-png")!;
const s = sanitizeSchemaParams(tool.schema, {
format: "YYYY-MM-DD",
style: {
font: "comic-sans",
size: -500,
bold: "no",
color: "red",
},
position: "somewhere-outside",
margin: 20,
plate: {
enabled: "yes",
color: "teal",
opacity: 500,
},
});
expect(s.position).toBe("bottom-right");
expect(s.style).toEqual({
font: "mono",
size: 8,
bold: false,
color: "#ffffff",
});
expect(s.plate).toEqual({
enabled: true,
color: "#000000",
opacity: 100,
});
const valid = sanitizeSchemaParams(tool.schema, {
...defaultSchemaParams(tool.schema),
position: "top-center",
style: { font: "serif", size: 120, bold: true, color: "#00ff00" },
});
expect(valid.position).toBe("top-center");
expect(valid.style).toEqual({
font: "serif",
size: 120,
bold: true,
color: "#00ff00",
});
});
it("text-to-png: дефолты font-style и sanitize", () => {
const tool = TOOLS.find((t) => t.id === "text-to-png")!;
const d = defaultSchemaParams(tool.schema);
expect(d.style).toEqual({
font: "sans",
size: 96,
bold: true,
color: "#111318",
});
expect(d.transparentBg).toBe(false);
const s = sanitizeSchemaParams(tool.schema, {
style: { font: "sans", size: 9999, bold: false, color: "#123abc" },
padding: 200,
});
expect(s.style).toEqual({
font: "sans",
size: 300,
bold: false,
color: "#123abc",
});
});
});
function solid(width: number, height: number) {
+125
View File
@@ -0,0 +1,125 @@
import { formatStamp } from "../core/datefmt";
import { drawTextBlock } from "../core/domText";
import type { Position9 } from "../core/textdraw";
import type { FontStyle, Plate } from "../registry-schema";
import { field, toolSchema } from "../registry-schema";
import type { ToolEntry } from "./types";
interface AddTextParams {
text: string;
style: FontStyle;
position: Position9;
margin: number;
plate: Plate;
}
const addTextSchema = toolSchema<AddTextParams>(
{
text: field.text({ default: "Hello!", placeholder: "Your text" }),
style: field.fontStyle({
min: 8,
max: 200,
size: 48,
font: "sans",
bold: true,
color: "#ffffff",
}),
position: field.position9({ default: "bottom-right" }),
margin: field.slider({ min: 0, max: 200, step: 1, default: 24 }),
plate: field.plate({ enabled: false, color: "#000000", opacity: 60 }),
},
{
layout: {
groups: [
{ title: "Text", fields: ["text", "style"] },
{ title: "Placement", fields: ["position", "margin"] },
{ title: "Plate", fields: ["plate"] },
],
},
},
);
const addText: ToolEntry<AddTextParams> = {
id: "add-text-png",
title: "Add text to PNG",
description:
"Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.",
category: "text",
domOnly: true,
schema: addTextSchema,
run: (img, p) =>
drawTextBlock(img, {
text: p.text,
fontSize: p.style.size,
font: p.style.font,
bold: p.style.bold,
color: p.style.color,
opacityPercent: 100,
position: p.position,
margin: p.margin,
plateColor: p.plate.enabled ? p.plate.color : undefined,
plateOpacityPercent: p.plate.opacity,
}),
};
interface DateStampParams {
format: string;
style: FontStyle;
position: Position9;
margin: number;
plate: Plate;
}
const dateStampSchema = toolSchema<DateStampParams>(
{
format: field.text({
default: "YYYY-MM-DD",
placeholder: "YYYY-MM-DD hh:mm",
}),
style: field.fontStyle({
min: 8,
max: 200,
size: 32,
font: "mono",
bold: false,
color: "#ffffff",
}),
position: field.position9({ default: "bottom-right" }),
margin: field.slider({ min: 0, max: 200, step: 1, default: 20 }),
plate: field.plate({ enabled: true, color: "#000000", opacity: 55 }),
},
{
layout: {
groups: [
{ title: "Text", fields: ["format", "style"] },
{ title: "Placement", fields: ["position", "margin"] },
{ title: "Plate", fields: ["plate"] },
],
},
},
);
const dateStamp: ToolEntry<DateStampParams> = {
id: "date-stamp-png",
title: "Date stamp PNG",
description:
"Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.",
category: "text",
domOnly: true,
schema: dateStampSchema,
run: (img, p) =>
drawTextBlock(img, {
text: formatStamp(new Date(), p.format),
fontSize: p.style.size,
font: p.style.font,
bold: p.style.bold,
color: p.style.color,
opacityPercent: 100,
position: p.position,
margin: p.margin,
plateColor: p.plate.enabled ? p.plate.color : undefined,
plateOpacityPercent: p.plate.opacity,
}),
};
export const textEntries = [addText, dateStamp];
+2
View File
@@ -13,6 +13,8 @@ export type ToolEntry<P = Record<string, unknown>> = {
description: string;
category: CategoryId;
schema: ToolSchema<P>;
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
domOnly?: boolean;
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
generate?(params: P): Promise<PixelImage> | PixelImage;
+28 -1
View File
@@ -26,7 +26,10 @@ const frameSchema = toolSchema<FrameParams>(
],
}),
},
{ layout: { group: "frame", cols: 2 }, label: "Frame" },
{
layout: { groups: [{ title: "Frame", fields: ["thickness", "color"] }] },
label: "Frame",
},
);
describe("registry-schema: дефолты из схемы", () => {
@@ -40,6 +43,30 @@ describe("registry-schema: дефолты из схемы", () => {
});
});
describe("registry-schema: раскладка (schema.layout)", () => {
it("toolSchema сохраняет группы полей", () => {
expect(frameSchema.layout).toEqual({
groups: [{ title: "Frame", fields: ["thickness", "color"] }],
});
});
it("обе схемы без layout не добавляют layout", () => {
const plain = toolSchema<FrameParams>({
thickness: field.slider({ min: 1, max: 500, default: 5 }),
color: field.color({ default: "#000000" }),
enabled: field.checkbox({ default: true }),
count: field.select({
default: "two",
options: [
{ value: "one", label: "One" },
{ value: "two", label: "Two" },
],
}),
});
expect(plain.layout).toBeUndefined();
});
});
describe("registry-schema: санитайз", () => {
it("пропускает валидные значения", () => {
const out = sanitizeSchemaParams(frameSchema, {
+228 -2
View File
@@ -11,6 +11,9 @@
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
import type { Position9 } from "./core/textdraw";
import type { TextFont } from "./core/domText";
export interface NumberSpec {
kind: "number";
default: number;
@@ -91,6 +94,84 @@ export interface OffsetSpec {
y: number;
}
/**
* 9-позиционная сетка (3×3): top/middle/bottom × left/center/right.
* Значение совпадает со строковым типом `Position9` из 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 {
kind: "position9";
default: Position9;
}
/**
* Стиль текстовой надписи: шрифт, размер, жирность и цвет как один объект
* (переиспользуется text-to-png, add-text, date-stamp).
*/
export interface FontStyle {
font: TextFont;
size: number;
bold: boolean;
color: string;
}
export interface FontStyleSpec {
kind: "font-style";
/** Диапазон размера шрифта. */
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}
/**
* Подложка-плашка под текстовой надписью: вкл/выкл, цвет и непрозрачность
* как один объект (add-text, date-stamp).
*/
export interface Plate {
enabled: boolean;
color: string;
opacity: number;
}
export interface PlateSpec {
kind: "plate";
enabled: boolean;
color: string;
opacity: number;
}
/**
* Цветовой градиент: пара цветов + угол направления. 0° слева направо,
* 90° сверху вниз (прирост по часовой в пиксельных осях, ось Y вниз).
*/
export interface Gradient {
from: string;
to: string;
angle: number;
}
export interface GradientSpec {
kind: "gradient";
from: string;
to: string;
/** Направление градиента: 0..360°, угол в градусах. */
angle: number;
}
/**
* «Объект as const» kind спека поля. Единственный источник правды для
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
@@ -107,6 +188,10 @@ export const fieldSpecs = {
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
offset: {} as OffsetSpec,
position9: {} as Position9Spec,
"font-style": {} as FontStyleSpec,
plate: {} as PlateSpec,
gradient: {} as GradientSpec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
@@ -120,16 +205,30 @@ export interface Field<T> {
__fieldT?: T;
}
export interface ToolLayoutGroup {
/** Подпись группы (пустая группа не рендерится). */
title?: string;
/** Количество колонок сетки внутри группы (по умолчанию 1). */
cols?: number;
/** Имена полей схемы, попадающих в группу. */
fields: string[];
}
export interface ToolSchemaLayout {
/** Группы полей виджета. Неупомянутые поля — в общей группе в конце. */
groups: ToolLayoutGroup[];
}
export interface ToolSchemaMeta {
/** Пер-инструмент раскладка полей (группировка/колонки). */
layout?: { group?: string; cols?: number };
layout?: ToolSchemaLayout;
/** Короткая подпись инструмента для нового UI (необязательно). */
label?: string;
}
export interface ToolSchema<P> {
fields: { [K in keyof P]: Field<P[K]> };
layout?: { group?: string; cols?: number };
layout?: ToolSchemaLayout;
label?: string;
}
@@ -171,6 +270,33 @@ export const field = {
}): Field<Offset> => ({
spec: { kind: "offset", ...s },
}),
position9: (s: { default: Position9 }): Field<Position9> => ({
spec: { kind: "position9", ...s },
}),
fontStyle: (s: {
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}): Field<FontStyle> => ({
spec: { kind: "font-style", ...s },
}),
plate: (s: {
enabled: boolean;
color: string;
opacity: number;
}): Field<Plate> => ({
spec: { kind: "plate", ...s },
}),
gradient: (s: {
from: string;
to: string;
angle: number;
}): Field<Gradient> => ({
spec: { kind: "gradient", ...s },
}),
};
/**
@@ -203,6 +329,25 @@ export function defaultSchemaParams<P>(
out[key] = { from: spec.from, to: spec.to };
} 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;
}
@@ -286,6 +431,62 @@ export function sanitizeSchemaParams<P>(
out[key] = { from, to };
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
@@ -301,6 +502,31 @@ export function sanitizeSchemaParams<P>(
};
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;
+1 -3
View File
@@ -1,5 +1,4 @@
import type { CategoryId } from "./categories";
import type { ToolSchema } from "./registry-schema";
import type { CategoryId } from "./categories";
import type { OutputMime } from "./core/io";
import type { PixelImage } from "./core/types";
@@ -63,7 +62,6 @@ export type ToolEntry<P = Record<string, unknown>> = {
category: CategoryId;
sourceMode?: SourceMode;
params: ParamDef[];
schema?: ToolSchema<P>;
run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
generate?: (params: P) => Promise<PixelImage> | PixelImage;
toText?: (img: PixelImage, params: P) => Promise<string> | string;
-12
View File
@@ -1,6 +1,5 @@
import type { ToolEntry } from "../registry";
import { ToolError } from "../core/errors";
import { field, toolSchema } from "../registry-schema";
import {
colorMask,
extractAlphaMask,
@@ -33,16 +32,6 @@ import {
import type { Position9 } from "../core/textdraw";
import { num, str } from "../registry-helpers";
interface AddStrokeParams {
color: string;
thickness: number;
}
const addStrokeSchema = toolSchema<AddStrokeParams>({
color: field.color({ default: "#ff0000" }),
thickness: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
});
export function alphaEntries(): ToolEntry[] {
return [
{
@@ -412,7 +401,6 @@ export function alphaEntries(): ToolEntry[] {
description:
"Adds a colored ring outline around the opaque content with the chosen thickness.",
category: "alpha",
schema: addStrokeSchema,
params: [
{
id: "color",
-12
View File
@@ -1,6 +1,5 @@
import type { ToolEntry } from "../registry";
import { ToolError } from "../core/errors";
import { field, toolSchema } from "../registry-schema";
import {
crop,
expandCanvas,
@@ -25,16 +24,6 @@ import {
} from "../core/affine";
import { num, str } from "../registry-helpers";
interface AddBorderParams {
thickness: number;
color: string;
}
const addBorderSchema = toolSchema<AddBorderParams>({
thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }),
color: field.color({ default: "#000000" }),
});
export function geometryEntries(): ToolEntry[] {
return [
{
@@ -241,7 +230,6 @@ export function geometryEntries(): ToolEntry[] {
description:
"Draws a colored frame of the chosen thickness around the image.",
category: "geometry",
schema: addBorderSchema,
params: [
{
id: "thickness",
@@ -1,6 +1,6 @@
<script lang="ts">
import { PREVIEW_GROUPS, PREVIEW_TOTAL } from "$lib/preview/catalog";
import { TOOL_ICONS } from "$lib/tools/tool-icons";
import { TOOL_ICONS } from "$lib/preview/tool-icons";
import CatalogHeader from "$lib/components/kit/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/kit/CatalogToolbar.svelte";
import CatalogGroup from "$lib/components/kit/CatalogGroup.svelte";