Compare commits
8
Commits
0c28925673
...
6cbb9f95f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cbb9f95f0 | ||
|
|
e57faddfde | ||
|
|
e662d1df24 | ||
|
|
d48262e482 | ||
|
|
cad9bf6cfd | ||
|
|
b99ccf5d78 | ||
|
|
0a7c4a84c9 | ||
|
|
f24ccf9240 |
@@ -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
@@ -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/`.
|
||||
|
||||
## Зависимости
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# План: дожать перевод инструментов в preview и переезд ветки `old/`
|
||||
|
||||
> Статус: **план.** Основа — `plan-composite-params.md` (Фазы 1–5). Текущее
|
||||
> состояние: **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ч** по фазам |
|
||||
|
||||
> Оценки ориентировочные; особые случаи — с ревью решения, объём финальных
|
||||
> правок по фидбеку не предсказуем.
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user