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` (словарь токенов) или локально в компоненте.
|
определён в `src/preview.css` (словарь токенов) или локально в компоненте.
|
||||||
Файл читается один раз и кэшируется (не `glob`-зависим).
|
Файл читается один раз и кэшируется (не `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 в
|
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
|
||||||
preview.css) — известный техдолг, его НЕ чинить и не игнорировать правилами.
|
preview.css) — известный техдолг, его НЕ чинить и не игнорировать правилами.
|
||||||
|
|
||||||
|
|||||||
+135
-23
@@ -9,11 +9,32 @@
|
|||||||
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
||||||
> step-colors, linear-gradient, two-colors); **составной тип `offset`
|
> step-colors, linear-gradient, two-colors); **составной тип `offset`
|
||||||
> полностью переведён** (4 инструмента: circle-mask, square-mask,
|
> полностью переведён** (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) и
|
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
||||||
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
||||||
> color-pair/offset).
|
> color-pair/offset/position9/font-style/plate/gradient). **Пер-инструмент
|
||||||
> Следующее: `position9` (add-text → date-stamp → watermark-image).
|
> раскладка `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`
|
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||||
@@ -43,7 +64,7 @@
|
|||||||
`lib/preview/categories.ts` (object as const) копирует и заменяет собой
|
`lib/preview/categories.ts` (object as const) копирует и заменяет собой
|
||||||
`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и
|
`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и
|
||||||
остаётся без изменений. После перехода (Фаза 5) копия становится основной,
|
остаётся без изменений. После перехода (Фаза 5) копия становится основной,
|
||||||
а исходник удаляется вместе со старым UI.
|
а исходник продолжает обслуживать `(old)` UI (он не удаляется — см. шаг 35).
|
||||||
|
|
||||||
**Разделение схем: две независимые схемы.**
|
**Разделение схем: две независимые схемы.**
|
||||||
|
|
||||||
@@ -63,8 +84,8 @@
|
|||||||
Помимо двух схем, registry тоже разделён по UI (по факту миграции):
|
Помимо двух схем, registry тоже разделён по UI (по факту миграции):
|
||||||
|
|
||||||
- **Старый registry** (`web/src/lib/registry.ts` + `registry/` +
|
- **Старый registry** (`web/src/lib/registry.ts` + `registry/` +
|
||||||
`registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]` и
|
`registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]`.
|
||||||
`tool-views.ts`. **Не трогаем**; идёт под удаление вместе со старым UI.
|
**Не трогаем**; остаётся обслуживать `(old)/` маршруты (см. шаг 35).
|
||||||
- **Новый registry** (`web/src/lib/registry-new/`) — строится **с нуля «как надо»**:
|
- **Новый registry** (`web/src/lib/registry-new/`) — строится **с нуля «как надо»**:
|
||||||
`ToolEntry<P>` с обязательным `schema`, типизированный `run`, **без** `ParamDef[]`
|
`ToolEntry<P>` с обязательным `schema`, типизированный `run`, **без** `ParamDef[]`
|
||||||
и **без** связи со старым. Импортирует core-функции (`expandCanvas`,
|
и **без** связи со старым. Импортирует core-функции (`expandCanvas`,
|
||||||
@@ -362,25 +383,112 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
|||||||
`offset: { x, y }` + `field.offset`, виджет
|
`offset: { x, y }` + `field.offset`, виджет
|
||||||
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
||||||
(default/sanitize). Тесты: +4 (600 passed).
|
(default/sanitize). Тесты: +4 (600 passed).
|
||||||
27. `position9` — перевести add-text → date-stamp → watermark-image
|
27. `position9` — **переведены `add-text-png` и `date-stamp-png`** ✔ (run в
|
||||||
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
|
`registry-new/text.ts`, оба `domOnly`). Составной тип во всех видах:
|
||||||
date-stamp
|
значение — строка `Position9`, kind `position9` в схеме (default/sanitize
|
||||||
29. `plate` — перевести add-text → date-stamp
|
по `POSITION9_VALUES`), виджет 3×3 `kit/fields/schema/PositionControl.svelte`.
|
||||||
30. `gradient` — собрать из dimension + color-pair + direction на
|
В `ToolEntry` добавлен флаг `domOnly` (превью-executor держит такие
|
||||||
linear-gradient (зависит от решения по gradient, см. план)
|
инструменты вне 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 на остальные инструменты
|
### Фаза 4 — масштаб UI на остальные инструменты
|
||||||
|
|
||||||
31. По мере перевода инструментов в Фазе 2-3 — рендер схемы (из Фазы 1)
|
31. По мере перевода инструментов в Фазе 2-3 — рендер схемы (из Фазы 1)
|
||||||
покрывает их автоматически; составные виджеты (dimension, color-pair,
|
покрывает их автоматически; составные виджеты (dimension, color-pair,
|
||||||
offset, position9, font-style, plate, gradient) — по одному, каждый с ревью.
|
offset, position9, font-style, plate, gradient) — по одному, каждый с ревью.
|
||||||
32. Пер-инструмент layout (`schema.layout`): группировка полей рамки и т.п.
|
32. Пер-инструмент layout (`schema.layout`) — **реализован** ✔.
|
||||||
33. Для каждого переведённого инструмента — UI-макет, принимается отдельно.
|
`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 инструмента).
|
34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента) ✔.
|
||||||
35. После перехода на новый дизайн — удаление старого UI и старого `ParamDef[]`.
|
Отдельного `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[]` как раньше.
|
на `ParamDef[]` как раньше.
|
||||||
- **Новый UI** (kit/`SchemaToolView` + `SchemaFields` + `SchemaPreview`, читает
|
- **Новый UI** (kit/`SchemaToolView` + `SchemaFields` + `SchemaPreview`, читает
|
||||||
`ToolSchema<P>`) — сейчас рендерит поля по схеме (number/slider/color).
|
`ToolSchema<P>`) — сейчас рендерит поля по схеме (number/slider/color).
|
||||||
- `tool-views.ts` — со временем поглощается registry (preview/lede/layout →
|
- `tool-views.ts` отсутствует — preview/lede/layout уже живут в meta
|
||||||
meta инструмента). Отдельный шаг, НЕ блокирует типизацию params.
|
инструмента (`ToolEntry` + `schema.layout`), дублирования нет.
|
||||||
|
|
||||||
## Оценка трудозатрат
|
## Оценка трудозатрат
|
||||||
|
|
||||||
@@ -431,15 +539,16 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
|||||||
| Миграция инструментов на interface Params + схемы | Механическая, но крупная | ~8-12ч |
|
| Миграция инструментов на interface Params + схемы | Механическая, но крупная | ~8-12ч |
|
||||||
| Новый рендер (kit/ParamControl) + составные виджеты | Средняя | ~4-5ч |
|
| Новый рендер (kit/ParamControl) + составные виджеты | Средняя | ~4-5ч |
|
||||||
| Поглощение tool-views.ts | Средняя | ~1-2ч |
|
| Поглощение tool-views.ts | Средняя | ~1-2ч |
|
||||||
| **Итого** | | **~20-29ч** (поэтапно) |
|
| Вынос старого UI в (old)/ + адаптация маршрутов | Средняя-Низкая | ~2-3ч |
|
||||||
|
| **Итого** | | **~22-32ч** (поэтапно) |
|
||||||
|
|
||||||
> Оценка выросла по сравнению с ранней версией плана, потому что принят путь
|
> Оценка выросла по сравнению с ранней версией плана, потому что принят путь
|
||||||
> «явный interface Params + схема + общий рендер с пер-инструмент layout» —
|
> «явный interface Params + схема + общий рендер с пер-инструмент layout» —
|
||||||
> это полный рефакторинг pipeline, а не только добавление составных типов.
|
> это полный рефакторинг pipeline, а не только добавление составных типов.
|
||||||
>
|
>
|
||||||
> Старый UI/`ParamDef[]`/старый pipeline в смету **не входят** — они не
|
> Старый UI/`ParamDef[]`/старый pipeline в смету **не входят** — они не
|
||||||
> рефакторятся, а продолжают работать до перехода (затем удаляются вместе со
|
> рефакторятся, а продолжают работать на `(old)/`-маршрутах до перехода
|
||||||
> старым дизайном).
|
> (затем убираются отдельным решением — см. шаг 35).
|
||||||
|
|
||||||
## Порядок реализации (кратко)
|
## Порядок реализации (кратко)
|
||||||
|
|
||||||
@@ -458,7 +567,10 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
|||||||
отдельно.
|
отдельно.
|
||||||
5. **Фаза 4** — масштаб UI на остальные: составные виджеты, пер-инструмент
|
5. **Фаза 4** — масштаб UI на остальные: составные виджеты, пер-инструмент
|
||||||
layout (для каждого инструмента — 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 prettier from "eslint-config-prettier";
|
||||||
import svelte from "eslint-plugin-svelte";
|
import svelte from "eslint-plugin-svelte";
|
||||||
import designTokens from "./eslint-plugins/index.js";
|
import designTokens from "./eslint-plugins/index.js";
|
||||||
|
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import svelteParser from "svelte-eslint-parser";
|
import svelteParser from "svelte-eslint-parser";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
@@ -114,6 +115,20 @@ export default tseslint.config(
|
|||||||
"prefer-const": "off",
|
"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 — последним, чтобы гасить форматирующие правила из recommended.
|
||||||
prettier,
|
prettier,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
import SelectControl from "./fields/schema/SelectControl.svelte";
|
import SelectControl from "./fields/schema/SelectControl.svelte";
|
||||||
import TextControl from "./fields/schema/TextControl.svelte";
|
import TextControl from "./fields/schema/TextControl.svelte";
|
||||||
import OffsetControl from "./fields/schema/OffsetControl.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 {
|
interface FieldControlProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -32,6 +36,10 @@
|
|||||||
text: TextControl,
|
text: TextControl,
|
||||||
dimension: DimensionField,
|
dimension: DimensionField,
|
||||||
offset: OffsetControl,
|
offset: OffsetControl,
|
||||||
|
position9: PositionControl,
|
||||||
|
"font-style": FontStyleControl,
|
||||||
|
plate: PlateControl,
|
||||||
|
gradient: GradientControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -48,16 +56,61 @@
|
|||||||
.replace(/[_-]+/g, " ")
|
.replace(/[_-]+/g, " ")
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
.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>
|
</script>
|
||||||
|
|
||||||
{#each Object.entries(schema.fields) as [id, field] (id)}
|
{#each layoutGroups as group (group.key)}
|
||||||
{@const Control = FIELDS[field.spec.kind]}
|
{#if group.title}
|
||||||
<Control
|
<span class="group-title">{group.title}</span>
|
||||||
label={labelOf(id)}
|
{/if}
|
||||||
value={values[id]}
|
<div
|
||||||
spec={field.spec}
|
class="group-fields"
|
||||||
onchange={(v) => onchange(id, v)}
|
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}
|
{/each}
|
||||||
|
|
||||||
<div class="panel-foot">
|
<div class="panel-foot">
|
||||||
@@ -68,6 +121,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.panel-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
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;
|
offset: Offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
const circleMaskSchema = toolSchema<CircleMaskParams>({
|
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 }),
|
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> = {
|
const circleMask: ToolEntry<CircleMaskParams> = {
|
||||||
id: "circle-mask-png",
|
id: "circle-mask-png",
|
||||||
@@ -104,11 +114,25 @@ interface SquareMaskParams {
|
|||||||
offset: Offset;
|
offset: Offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
const squareMaskSchema = toolSchema<SquareMaskParams>({
|
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 }),
|
widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
|
||||||
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
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> = {
|
const squareMask: ToolEntry<SquareMaskParams> = {
|
||||||
id: "square-mask-png",
|
id: "square-mask-png",
|
||||||
@@ -134,13 +158,27 @@ interface StarMaskParams {
|
|||||||
offset: Offset;
|
offset: Offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
const starMaskSchema = toolSchema<StarMaskParams>({
|
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 }),
|
points: field.slider({ min: 3, max: 12, step: 1, default: 5 }),
|
||||||
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
|
innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }),
|
||||||
rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
|
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
|
||||||
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
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> = {
|
const starMask: ToolEntry<StarMaskParams> = {
|
||||||
id: "star-mask-png",
|
id: "star-mask-png",
|
||||||
@@ -166,13 +204,27 @@ interface WavyMaskParams {
|
|||||||
offset: Offset;
|
offset: Offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wavyMaskSchema = toolSchema<WavyMaskParams>({
|
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 }),
|
size: field.slider({ min: 20, max: 100, step: 1, default: 90 }),
|
||||||
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
|
amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }),
|
||||||
phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }),
|
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
|
||||||
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
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> = {
|
const wavyMask: ToolEntry<WavyMaskParams> = {
|
||||||
id: "wavy-mask-png",
|
id: "wavy-mask-png",
|
||||||
|
|||||||
@@ -45,10 +45,17 @@ interface RandomizePixelsParams {
|
|||||||
seed: number;
|
seed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const randomizePixelsSchema = toolSchema<RandomizePixelsParams>({
|
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 }),
|
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> = {
|
const randomizePixels: ToolEntry<RandomizePixelsParams> = {
|
||||||
id: "randomize-pixels-png",
|
id: "randomize-pixels-png",
|
||||||
@@ -66,17 +73,27 @@ interface AddNoiseParams {
|
|||||||
seed: number;
|
seed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addNoiseSchema = toolSchema<AddNoiseParams>({
|
export const addNoiseSchema = toolSchema<AddNoiseParams>(
|
||||||
amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }),
|
{
|
||||||
mode: field.select({
|
amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }),
|
||||||
default: "mono",
|
mode: field.select({
|
||||||
options: [
|
default: "mono",
|
||||||
{ value: "mono", label: "Monochrome grain" },
|
options: [
|
||||||
{ value: "color", label: "Color noise" },
|
{ value: "mono", label: "Monochrome grain" },
|
||||||
],
|
{ value: "color", label: "Color noise" },
|
||||||
}),
|
],
|
||||||
seed: field.number({ min: 0, max: 999999, step: 1, default: 1234 }),
|
}),
|
||||||
});
|
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> = {
|
const addNoiseTool: ToolEntry<AddNoiseParams> = {
|
||||||
id: "add-noise-png",
|
id: "add-noise-png",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { renderTextToImage } from "../core/domText";
|
import { renderTextToImage } from "../core/domText";
|
||||||
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
|
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 { changeCanvasSize } from "../core/geometry";
|
||||||
|
import type { PixelImage } from "../core/types";
|
||||||
import {
|
import {
|
||||||
hexToRgb,
|
hexToRgb,
|
||||||
renderBlend,
|
renderBlend,
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
toolSchema,
|
toolSchema,
|
||||||
type ColorPair,
|
type ColorPair,
|
||||||
type Dimension,
|
type Dimension,
|
||||||
|
type FontStyle,
|
||||||
|
type Gradient,
|
||||||
} from "../registry-schema";
|
} from "../registry-schema";
|
||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
|
|
||||||
@@ -21,17 +24,66 @@ function rgba(hex: string): [number, number, number, number] {
|
|||||||
return [r, g, b, 255];
|
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 {
|
interface CreateEmptyParams {
|
||||||
size: Dimension;
|
size: Dimension;
|
||||||
transparent: boolean;
|
transparent: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createEmptySchema = toolSchema<CreateEmptyParams>({
|
export const createEmptySchema = toolSchema<CreateEmptyParams>(
|
||||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
{
|
||||||
transparent: field.checkbox({ default: true }),
|
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||||
color: field.color({ default: "#ffffff" }),
|
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> = {
|
const createEmpty: ToolEntry<CreateEmptyParams> = {
|
||||||
id: "create-empty-png",
|
id: "create-empty-png",
|
||||||
@@ -99,33 +151,41 @@ const randomNoise: ToolEntry<RandomNoiseParams> = {
|
|||||||
|
|
||||||
interface LinearGradientParams {
|
interface LinearGradientParams {
|
||||||
size: Dimension;
|
size: Dimension;
|
||||||
pair: ColorPair;
|
gradient: Gradient;
|
||||||
direction: "horizontal" | "vertical";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const linearGradientSchema = toolSchema<LinearGradientParams>({
|
export const linearGradientSchema = toolSchema<LinearGradientParams>(
|
||||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
{
|
||||||
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
|
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||||
direction: field.select({
|
gradient: field.gradient({ from: "#000000", to: "#ffffff", angle: 0 }),
|
||||||
default: "horizontal",
|
},
|
||||||
options: [
|
{
|
||||||
{ value: "horizontal", label: "Horizontal" },
|
layout: {
|
||||||
{ value: "vertical", label: "Vertical" },
|
groups: [
|
||||||
],
|
{ title: "Canvas", fields: ["size"] },
|
||||||
}),
|
{ title: "Colors", fields: ["gradient"] },
|
||||||
});
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const linearGradient: ToolEntry<LinearGradientParams> = {
|
const linearGradient: ToolEntry<LinearGradientParams> = {
|
||||||
id: "linear-gradient-png",
|
id: "linear-gradient-png",
|
||||||
title: "Create gradient PNG",
|
title: "Create gradient PNG",
|
||||||
description:
|
description:
|
||||||
"Generates a smooth transition between two colors, horizontally or vertically.",
|
"Generates a smooth transition between two colors along a chosen angle.",
|
||||||
category: "generate",
|
category: "generate",
|
||||||
schema: linearGradientSchema,
|
schema: linearGradientSchema,
|
||||||
generate: (p) => {
|
generate: (p) => {
|
||||||
const w = Math.trunc(p.size.width);
|
const w = Math.trunc(p.size.width);
|
||||||
const h = Math.trunc(p.size.height);
|
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;
|
lightness: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>({
|
export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>(
|
||||||
size: field.dimension({ min: 1, max: 5000, width: 1024, height: 128 }),
|
{
|
||||||
direction: field.select({
|
size: field.dimension({ min: 1, max: 5000, width: 1024, height: 128 }),
|
||||||
default: "horizontal",
|
direction: field.select({
|
||||||
options: [
|
default: "horizontal",
|
||||||
{ value: "horizontal", label: "Horizontal" },
|
options: [
|
||||||
{ value: "vertical", label: "Vertical" },
|
{ 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 }),
|
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> = {
|
const colorSpectrumTool: ToolEntry<ColorSpectrumParams> = {
|
||||||
id: "color-spectrum-png",
|
id: "color-spectrum-png",
|
||||||
@@ -169,11 +239,21 @@ interface RandomColorsParams {
|
|||||||
seed: number;
|
seed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const randomColorsSchema = toolSchema<RandomColorsParams>({
|
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 }),
|
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
|
||||||
seed: field.number({ min: 0, max: 999999999, step: 1, default: 7 }),
|
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> = {
|
const randomColors: ToolEntry<RandomColorsParams> = {
|
||||||
id: "random-colors-png",
|
id: "random-colors-png",
|
||||||
@@ -198,14 +278,28 @@ interface DrawGridParams {
|
|||||||
transparentBg: boolean;
|
transparentBg: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const drawGridSchema = toolSchema<DrawGridParams>({
|
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 }),
|
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
|
||||||
rows: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
|
cols: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
|
||||||
lineWidth: field.slider({ min: 1, max: 40, step: 1, default: 2 }),
|
rows: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
|
||||||
color: field.color({ default: "#111318" }),
|
lineWidth: field.slider({ min: 1, max: 40, step: 1, default: 2 }),
|
||||||
transparentBg: field.checkbox({ default: true }),
|
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> = {
|
const drawGridTool: ToolEntry<DrawGridParams> = {
|
||||||
id: "draw-grid-png",
|
id: "draw-grid-png",
|
||||||
@@ -236,12 +330,23 @@ interface PlaceholderParams {
|
|||||||
showText: boolean;
|
showText: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const placeholderSchema = toolSchema<PlaceholderParams>({
|
export const placeholderSchema = toolSchema<PlaceholderParams>(
|
||||||
size: field.dimension({ min: 1, max: 5000, width: 800, height: 400 }),
|
{
|
||||||
backgroundColor: field.color({ default: "#dfe2e8" }),
|
size: field.dimension({ min: 1, max: 5000, width: 800, height: 400 }),
|
||||||
color: field.color({ default: "#5c6470" }),
|
backgroundColor: field.color({ default: "#dfe2e8" }),
|
||||||
showText: field.checkbox({ default: true }),
|
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> = {
|
const placeholder: ToolEntry<PlaceholderParams> = {
|
||||||
id: "placeholder-png",
|
id: "placeholder-png",
|
||||||
@@ -297,18 +402,28 @@ interface StepColorsParams {
|
|||||||
layout: "strip" | "grid";
|
layout: "strip" | "grid";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stepColorsSchema = toolSchema<StepColorsParams>({
|
export const stepColorsSchema = toolSchema<StepColorsParams>(
|
||||||
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
|
{
|
||||||
steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }),
|
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
|
||||||
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }),
|
||||||
layout: field.select({
|
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||||
default: "grid",
|
layout: field.select({
|
||||||
options: [
|
default: "grid",
|
||||||
{ value: "grid", label: "Grid" },
|
options: [
|
||||||
{ value: "strip", label: "Strip" },
|
{ 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> = {
|
const stepColorsTool: ToolEntry<StepColorsParams> = {
|
||||||
id: "step-colors-png",
|
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 = [
|
export const generateEntries = [
|
||||||
createEmpty,
|
createEmpty,
|
||||||
singleColor,
|
singleColor,
|
||||||
@@ -335,4 +505,5 @@ export const generateEntries = [
|
|||||||
placeholder,
|
placeholder,
|
||||||
blendTwo,
|
blendTwo,
|
||||||
stepColorsTool,
|
stepColorsTool,
|
||||||
|
textToPng,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -43,11 +43,21 @@ interface FitOnBackgroundParams {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>({
|
export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
|
||||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
{
|
||||||
transparent: field.checkbox({ default: false }),
|
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||||
color: field.color({ default: "#ffffff" }),
|
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> = {
|
const fitOnBackground: ToolEntry<FitOnBackgroundParams> = {
|
||||||
id: "fit-on-background-png",
|
id: "fit-on-background-png",
|
||||||
@@ -80,23 +90,33 @@ interface ChangeCanvasSizeParams {
|
|||||||
anchor: Anchor9;
|
anchor: Anchor9;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>({
|
export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>(
|
||||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
{
|
||||||
anchor: field.select({
|
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||||
default: "center",
|
anchor: field.select({
|
||||||
options: [
|
default: "center",
|
||||||
{ value: "top-left", label: "Top left" },
|
options: [
|
||||||
{ value: "top-center", label: "Top center" },
|
{ value: "top-left", label: "Top left" },
|
||||||
{ value: "top-right", label: "Top right" },
|
{ value: "top-center", label: "Top center" },
|
||||||
{ value: "middle-left", label: "Middle left" },
|
{ value: "top-right", label: "Top right" },
|
||||||
{ value: "center", label: "Center" },
|
{ value: "middle-left", label: "Middle left" },
|
||||||
{ value: "middle-right", label: "Middle right" },
|
{ value: "center", label: "Center" },
|
||||||
{ value: "bottom-left", label: "Bottom left" },
|
{ value: "middle-right", label: "Middle right" },
|
||||||
{ value: "bottom-center", label: "Bottom center" },
|
{ value: "bottom-left", label: "Bottom left" },
|
||||||
{ value: "bottom-right", label: "Bottom right" },
|
{ 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> = {
|
const changeCanvasSizeTool: ToolEntry<ChangeCanvasSizeParams> = {
|
||||||
id: "change-canvas-size-png",
|
id: "change-canvas-size-png",
|
||||||
@@ -119,10 +139,20 @@ interface ResizeParams {
|
|||||||
keepAspect: boolean;
|
keepAspect: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resizeSchema = toolSchema<ResizeParams>({
|
export const resizeSchema = toolSchema<ResizeParams>(
|
||||||
size: field.dimension({ min: 0, max: 20000, width: 0, height: 0 }),
|
{
|
||||||
keepAspect: field.checkbox({ default: true }),
|
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> = {
|
const resizeTool: ToolEntry<ResizeParams> = {
|
||||||
id: "resize-png",
|
id: "resize-png",
|
||||||
@@ -159,11 +189,21 @@ interface CropParams {
|
|||||||
size: Dimension;
|
size: Dimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cropSchema = toolSchema<CropParams>({
|
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 }),
|
x: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
|
||||||
size: field.dimension({ min: 0, max: 100000, width: 0, height: 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> = {
|
const cropTool: ToolEntry<CropParams> = {
|
||||||
id: "crop-png",
|
id: "crop-png",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { analyzeEntries } from "./analyze";
|
|||||||
import { filtersEntries } from "./filters";
|
import { filtersEntries } from "./filters";
|
||||||
import { colorEntries } from "./color";
|
import { colorEntries } from "./color";
|
||||||
import { generateEntries } from "./generate";
|
import { generateEntries } from "./generate";
|
||||||
|
import { textEntries } from "./text";
|
||||||
|
|
||||||
export type { ToolEntry } from "./types";
|
export type { ToolEntry } from "./types";
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
...filtersEntries,
|
...filtersEntries,
|
||||||
...colorEntries,
|
...colorEntries,
|
||||||
...generateEntries,
|
...generateEntries,
|
||||||
|
...textEntries,
|
||||||
] as unknown as ToolEntry[];
|
] as unknown as ToolEntry[];
|
||||||
|
|
||||||
export function getTool(id: string): ToolEntry | undefined {
|
export function getTool(id: string): ToolEntry | undefined {
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
"linear-gradient-png",
|
"linear-gradient-png",
|
||||||
{
|
{
|
||||||
size: { width: 40, height: 30 },
|
size: { width: 40, height: 30 },
|
||||||
pair: { from: "#000000", to: "#ffffff" },
|
gradient: {
|
||||||
direction: "horizontal",
|
from: "#000000",
|
||||||
|
to: "#ffffff",
|
||||||
|
angle: 90,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -235,6 +238,61 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
expect(out.data[102]).toBe(255);
|
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", () => {
|
it("sanitize чинит мусор в паре и клампит threshold", () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
|
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
|
||||||
const s = sanitizeSchemaParams(tool.schema, {
|
const s = sanitizeSchemaParams(tool.schema, {
|
||||||
@@ -304,6 +362,90 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
});
|
});
|
||||||
expect(s.offset).toEqual({ x: 50, y: 0 });
|
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) {
|
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;
|
description: string;
|
||||||
category: CategoryId;
|
category: CategoryId;
|
||||||
schema: ToolSchema<P>;
|
schema: ToolSchema<P>;
|
||||||
|
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
|
||||||
|
domOnly?: boolean;
|
||||||
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
|
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
|
||||||
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
||||||
generate?(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: дефолты из схемы", () => {
|
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: санитайз", () => {
|
describe("registry-schema: санитайз", () => {
|
||||||
it("пропускает валидные значения", () => {
|
it("пропускает валидные значения", () => {
|
||||||
const out = sanitizeSchemaParams(frameSchema, {
|
const out = sanitizeSchemaParams(frameSchema, {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
|
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
|
||||||
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
|
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
|
||||||
|
|
||||||
|
import type { Position9 } from "./core/textdraw";
|
||||||
|
import type { TextFont } from "./core/domText";
|
||||||
|
|
||||||
export interface NumberSpec {
|
export interface NumberSpec {
|
||||||
kind: "number";
|
kind: "number";
|
||||||
default: number;
|
default: number;
|
||||||
@@ -91,6 +94,84 @@ export interface OffsetSpec {
|
|||||||
y: number;
|
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 → спека поля. Единственный источник правды для
|
* «Объект as const» kind → спека поля. Единственный источник правды для
|
||||||
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
|
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
|
||||||
@@ -107,6 +188,10 @@ export const fieldSpecs = {
|
|||||||
dimension: {} as DimensionSpec,
|
dimension: {} as DimensionSpec,
|
||||||
"color-pair": {} as ColorPairSpec,
|
"color-pair": {} as ColorPairSpec,
|
||||||
offset: {} as OffsetSpec,
|
offset: {} as OffsetSpec,
|
||||||
|
position9: {} as Position9Spec,
|
||||||
|
"font-style": {} as FontStyleSpec,
|
||||||
|
plate: {} as PlateSpec,
|
||||||
|
gradient: {} as GradientSpec,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FieldSpecKind = keyof typeof fieldSpecs;
|
export type FieldSpecKind = keyof typeof fieldSpecs;
|
||||||
@@ -120,16 +205,30 @@ export interface Field<T> {
|
|||||||
__fieldT?: T;
|
__fieldT?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolLayoutGroup {
|
||||||
|
/** Подпись группы (пустая группа не рендерится). */
|
||||||
|
title?: string;
|
||||||
|
/** Количество колонок сетки внутри группы (по умолчанию 1). */
|
||||||
|
cols?: number;
|
||||||
|
/** Имена полей схемы, попадающих в группу. */
|
||||||
|
fields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolSchemaLayout {
|
||||||
|
/** Группы полей виджета. Неупомянутые поля — в общей группе в конце. */
|
||||||
|
groups: ToolLayoutGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolSchemaMeta {
|
export interface ToolSchemaMeta {
|
||||||
/** Пер-инструмент раскладка полей (группировка/колонки). */
|
/** Пер-инструмент раскладка полей (группировка/колонки). */
|
||||||
layout?: { group?: string; cols?: number };
|
layout?: ToolSchemaLayout;
|
||||||
/** Короткая подпись инструмента для нового UI (необязательно). */
|
/** Короткая подпись инструмента для нового UI (необязательно). */
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolSchema<P> {
|
export interface ToolSchema<P> {
|
||||||
fields: { [K in keyof P]: Field<P[K]> };
|
fields: { [K in keyof P]: Field<P[K]> };
|
||||||
layout?: { group?: string; cols?: number };
|
layout?: ToolSchemaLayout;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +270,33 @@ export const field = {
|
|||||||
}): Field<Offset> => ({
|
}): Field<Offset> => ({
|
||||||
spec: { kind: "offset", ...s },
|
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 };
|
out[key] = { from: spec.from, to: spec.to };
|
||||||
} else if (spec.kind === "offset") {
|
} else if (spec.kind === "offset") {
|
||||||
out[key] = { x: spec.x, y: spec.y };
|
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 {
|
} else {
|
||||||
out[key] = spec.default;
|
out[key] = spec.default;
|
||||||
}
|
}
|
||||||
@@ -286,6 +431,62 @@ export function sanitizeSchemaParams<P>(
|
|||||||
out[key] = { from, to };
|
out[key] = { from, to };
|
||||||
break;
|
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": {
|
case "offset": {
|
||||||
const r =
|
const r =
|
||||||
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
|
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
|
||||||
@@ -301,6 +502,31 @@ export function sanitizeSchemaParams<P>(
|
|||||||
};
|
};
|
||||||
break;
|
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;
|
return out;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { CategoryId } from "./categories";
|
import type { CategoryId } from "./categories";
|
||||||
import type { ToolSchema } from "./registry-schema";
|
|
||||||
import type { OutputMime } from "./core/io";
|
import type { OutputMime } from "./core/io";
|
||||||
import type { PixelImage } from "./core/types";
|
import type { PixelImage } from "./core/types";
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ export type ToolEntry<P = Record<string, unknown>> = {
|
|||||||
category: CategoryId;
|
category: CategoryId;
|
||||||
sourceMode?: SourceMode;
|
sourceMode?: SourceMode;
|
||||||
params: ParamDef[];
|
params: ParamDef[];
|
||||||
schema?: ToolSchema<P>;
|
|
||||||
run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
||||||
generate?: (params: P) => Promise<PixelImage> | PixelImage;
|
generate?: (params: P) => Promise<PixelImage> | PixelImage;
|
||||||
toText?: (img: PixelImage, params: P) => Promise<string> | string;
|
toText?: (img: PixelImage, params: P) => Promise<string> | string;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
import type { ToolEntry } from "../registry";
|
||||||
import { ToolError } from "../core/errors";
|
import { ToolError } from "../core/errors";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
|
||||||
import {
|
import {
|
||||||
colorMask,
|
colorMask,
|
||||||
extractAlphaMask,
|
extractAlphaMask,
|
||||||
@@ -33,16 +32,6 @@ import {
|
|||||||
import type { Position9 } from "../core/textdraw";
|
import type { Position9 } from "../core/textdraw";
|
||||||
import { num, str } from "../registry-helpers";
|
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[] {
|
export function alphaEntries(): ToolEntry[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -412,7 +401,6 @@ export function alphaEntries(): ToolEntry[] {
|
|||||||
description:
|
description:
|
||||||
"Adds a colored ring outline around the opaque content with the chosen thickness.",
|
"Adds a colored ring outline around the opaque content with the chosen thickness.",
|
||||||
category: "alpha",
|
category: "alpha",
|
||||||
schema: addStrokeSchema,
|
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
id: "color",
|
id: "color",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
import type { ToolEntry } from "../registry";
|
||||||
import { ToolError } from "../core/errors";
|
import { ToolError } from "../core/errors";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
|
||||||
import {
|
import {
|
||||||
crop,
|
crop,
|
||||||
expandCanvas,
|
expandCanvas,
|
||||||
@@ -25,16 +24,6 @@ import {
|
|||||||
} from "../core/affine";
|
} from "../core/affine";
|
||||||
import { num, str } from "../registry-helpers";
|
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[] {
|
export function geometryEntries(): ToolEntry[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -241,7 +230,6 @@ export function geometryEntries(): ToolEntry[] {
|
|||||||
description:
|
description:
|
||||||
"Draws a colored frame of the chosen thickness around the image.",
|
"Draws a colored frame of the chosen thickness around the image.",
|
||||||
category: "geometry",
|
category: "geometry",
|
||||||
schema: addBorderSchema,
|
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
id: "thickness",
|
id: "thickness",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PREVIEW_GROUPS, PREVIEW_TOTAL } from "$lib/preview/catalog";
|
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 CatalogHeader from "$lib/components/kit/CatalogHeader.svelte";
|
||||||
import CatalogToolbar from "$lib/components/kit/CatalogToolbar.svelte";
|
import CatalogToolbar from "$lib/components/kit/CatalogToolbar.svelte";
|
||||||
import CatalogGroup from "$lib/components/kit/CatalogGroup.svelte";
|
import CatalogGroup from "$lib/components/kit/CatalogGroup.svelte";
|
||||||
|
|||||||
Reference in New Issue
Block a user