mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: remove v1 routes and libs
This commit is contained in:
@@ -20,9 +20,8 @@
|
|||||||
`docs/quality-gates.md`.
|
`docs/quality-gates.md`.
|
||||||
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
|
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
|
||||||
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
|
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
|
||||||
выполняется, даже если предыдущий упал. `app_v1.css` и legacy-ветка исключены
|
выполняется, даже если предыдущий упал. Новые изменения не должны добавлять
|
||||||
из новых правил; их долг чинится только по заведённым задачам, а новые
|
предупреждения без записи в baseline.
|
||||||
изменения не должны добавлять новые предупреждения без записи в baseline.
|
|
||||||
|
|
||||||
## Правила кода
|
## Правила кода
|
||||||
|
|
||||||
@@ -49,8 +48,7 @@
|
|||||||
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
||||||
которые линтер не проверяет:
|
которые линтер не проверяет:
|
||||||
|
|
||||||
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
|
- Новый дизайн живёт в `web/src/app.css` и применяется ко всем production-маршрутам.
|
||||||
`web/src/app_v1.css` (маршруты `/v1/*`).
|
|
||||||
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
||||||
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
||||||
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
||||||
@@ -69,10 +67,5 @@
|
|||||||
дату архивирования и срок удаления; архив — только справка, не источник
|
дату архивирования и срок удаления; архив — только справка, не источник
|
||||||
требований.
|
требований.
|
||||||
|
|
||||||
## Изоляция веток old ↔ new
|
|
||||||
|
|
||||||
Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
|
|
||||||
каждый импорт до файла и запрещает смешивание.
|
|
||||||
|
|
||||||
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
|
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
|
||||||
Изменения делать небольшими (< ~500 строк), атомарными.
|
Изменения делать небольшими (< ~500 строк), атомарными.
|
||||||
|
|||||||
+10
-10
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Порядок чтения
|
## Порядок чтения
|
||||||
|
|
||||||
1. `AGENTS.md` — команды, обязательные инварианты и границы old/new.
|
1. `AGENTS.md` — команды и обязательные инварианты.
|
||||||
2. Этот файл — выбрать актуальный документ по теме.
|
2. Этот файл — выбрать актуальный документ по теме.
|
||||||
3. `docs/plan-codebase-quality.md` — текущий план приведения качества в порядок
|
3. `docs/plan-codebase-quality.md` — текущий план приведения качества в порядок
|
||||||
и последовательность следующих шагов.
|
и последовательность следующих шагов.
|
||||||
@@ -23,8 +23,7 @@
|
|||||||
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
|
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
|
||||||
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
|
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
|
||||||
| `docs/roadmap.md` | Общие направления развития | active |
|
| `docs/roadmap.md` | Общие направления развития | active |
|
||||||
| `docs/plan-redesign.md` | Новый дизайн и границы `/v1` | active |
|
| `docs/plan-redesign.md` | Новый дизайн production UI | active |
|
||||||
| `docs/plan-preview-i18n.md` | План i18n нового UI | active |
|
|
||||||
| `docs/plan-seo.md` | SEO/GEO-план | active |
|
| `docs/plan-seo.md` | SEO/GEO-план | active |
|
||||||
| `docs/plan-platform.md` | PWA/API/WASM-план | active |
|
| `docs/plan-platform.md` | PWA/API/WASM-план | active |
|
||||||
| `docs/checklist-manual-testing.md` | Ручная приёмка preview | active |
|
| `docs/checklist-manual-testing.md` | Ручная приёмка preview | active |
|
||||||
@@ -51,16 +50,17 @@
|
|||||||
только если темы нет в существующем документе; иначе обновляется источник
|
только если темы нет в существующем документе; иначе обновляется источник
|
||||||
истины.
|
истины.
|
||||||
|
|
||||||
## Текущий baseline Q0
|
## Текущий baseline Q3
|
||||||
|
|
||||||
На момент фиксации Q0:
|
На момент фиксации Q3:
|
||||||
|
|
||||||
- registry нового UI содержит 122 инструмента;
|
- production registry содержит 122 инструмента;
|
||||||
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
||||||
- `pnpm --dir web lint:all` завершается с 0 errors и 0 i18n warnings; остаются 4
|
- `pnpm --dir web test` — 39 test files / 422 tests;
|
||||||
unused design tokens от token audit;
|
- `pnpm --dir web test:rules` — 4 test files / 74 tests;
|
||||||
- `pnpm verify` проходит; unit/e2e результаты считать актуальными только после
|
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
|
||||||
отдельного прогона, исторические числа в `docs/archive/` не являются baseline.
|
design tokens от token audit;
|
||||||
|
- `pnpm verify` проходит; production E2E — 105 passed, 5 skipped.
|
||||||
|
|
||||||
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@
|
|||||||
4. Найти существующие типы, константы, helpers и тестовые паттерны до создания
|
4. Найти существующие типы, константы, helpers и тестовые паттерны до создания
|
||||||
нового определения.
|
нового определения.
|
||||||
5. Определить границу изменения:
|
5. Определить границу изменения:
|
||||||
- новый UI — `web/src/routes/**` и `web/src/lib/components/**`;
|
- production UI — `web/src/routes/**` и `web/src/lib/components/**`;
|
||||||
- legacy — `web/src/routes/v1/**` и `web/src/lib/v1/**`;
|
|
||||||
- shared — `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts`.
|
- shared — `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts`.
|
||||||
|
|
||||||
Изменение shared-кода требует проверки обеих веток. Смешивание old/new нельзя
|
Изменение shared-кода требует проверки production-потребителей. Не добавлять
|
||||||
обходить относительными импортами или исключениями линтера.
|
исключения линтера ради отдельных модулей без записи в baseline.
|
||||||
|
|
||||||
## 2. Порядок изменения
|
## 2. Порядок изменения
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ E2E запускается отдельно от быстрых проверок
|
|||||||
- обязательное поведение агента — `AGENTS.md`;
|
- обязательное поведение агента — `AGENTS.md`;
|
||||||
- индекс и карта документов — `docs/README.md`;
|
- индекс и карта документов — `docs/README.md`;
|
||||||
- качество и точные команды — `docs/quality-gates.md`;
|
- качество и точные команды — `docs/quality-gates.md`;
|
||||||
- тестовая архитектура — `docs/testing-strategy.md` после Q3;
|
- тестовая архитектура — `docs/testing-strategy.md` после Q4;
|
||||||
- lint implementation — `web/eslint-plugins/README.md`;
|
- lint implementation — `web/eslint-plugins/README.md`;
|
||||||
- продуктовые решения — backlog и активные планы.
|
- продуктовые решения — backlog и активные планы.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
|
||||||
|
> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в
|
||||||
|
> backlog. Использование: только историческая справка
|
||||||
|
|
||||||
# План: i18n в preview (новый UI)
|
# План: i18n в preview (новый UI)
|
||||||
|
|
||||||
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
|
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
|
||||||
+7
-10
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`.
|
||||||
`docs/plan-preview-i18n.md`. Правило реализовано; Q2 перевёл i18n baseline
|
Правило реализовано; Q2 перевёл i18n baseline к zero-warning через
|
||||||
к zero-warning через policy-aware fallback, не меняя `/v1`.
|
policy-aware fallback.
|
||||||
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
||||||
что за `SonarLint` или написать кастомный линтер)
|
что за `SonarLint` или написать кастомный линтер)
|
||||||
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
||||||
оно должно быть у всех или объяснено, почему этот инструмент такой
|
оно должно быть у всех или объяснено, почему этот инструмент такой
|
||||||
особенный и заслуживает комментарий
|
особенный и заслуживает комментарий
|
||||||
- [ ] удалить legacy `tools.*.params` из root i18n после проверки потребителей;
|
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
|
||||||
до этого Q2 держит pattern-based policy-исключение
|
до этого Q2 держит pattern-based policy-исключение
|
||||||
|
|
||||||
## Мелочи всякие
|
## Мелочи всякие
|
||||||
@@ -53,8 +53,7 @@
|
|||||||
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
||||||
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
|
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
|
||||||
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
|
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
|
||||||
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность
|
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
||||||
(range?)
|
|
||||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
||||||
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
||||||
значения
|
значения
|
||||||
@@ -75,8 +74,7 @@
|
|||||||
|
|
||||||
- [x] добавить i18n в preview
|
- [x] добавить i18n в preview
|
||||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||||
`tPlural()` и формат `PluralForms`, детали в §Фаза 9
|
`tPlural()` и формат `PluralForms`; детали и критерии — в этом backlog
|
||||||
`docs/plan-preview-i18n.md`
|
|
||||||
|
|
||||||
## SEO/GEO
|
## SEO/GEO
|
||||||
|
|
||||||
@@ -103,8 +101,7 @@
|
|||||||
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
|
Общий план: `docs/plan-platform.md`; WASM-ядро и CLI уже в `docs/roadmap.md`
|
||||||
(фазы 3–8) — здесь только новые треки.
|
(фазы 3–8) — здесь только новые треки.
|
||||||
|
|
||||||
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после C17 и S1;
|
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
|
||||||
C19 отложен — не блокер)
|
|
||||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||||
(после wasm-фаз 5–7)
|
(после wasm-фаз 5–7)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# План: линтеры, тесты и качество кодовой базы
|
# План: линтеры, тесты и качество кодовой базы
|
||||||
|
|
||||||
> Статус: **draft — на ревью**.
|
> Статус: **draft — Q0–Q3 готовы к ревью; Q4 — следующий этап**.
|
||||||
>
|
>
|
||||||
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
||||||
> инфраструктурных скриптов. До подтверждения плана кодовые изменения не
|
> инфраструктурных скриптов. Q0–Q3 выполнены; Q4–Q8 выполняются отдельными
|
||||||
> выполняются.
|
> атомарными шагами.
|
||||||
>
|
>
|
||||||
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
||||||
> проверок, стабилизировать тестовые контракты и только после этого расширять
|
> проверок, стабилизировать тестовые контракты и только после этого расширять
|
||||||
@@ -14,51 +14,40 @@
|
|||||||
|
|
||||||
1. Сначала stabilизировать существующие проверки и тестовые контракты, а не
|
1. Сначала stabilизировать существующие проверки и тестовые контракты, а не
|
||||||
добавлять новые сложные lint-правила.
|
добавлять новые сложные lint-правила.
|
||||||
2. Старый UI (`src/lib/v1/`, `src/routes/v1/`, `app_v1.css`) больше не является
|
2. Старый UI удалён: production работает на корневых маршрутах, а `/v1/**` и
|
||||||
обязательной поддерживаемой версией: текущий production на корневых маршрутах
|
v1-specific код больше не являются частью приложения.
|
||||||
уже достаточно стабилен для использования.
|
3. Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты.
|
||||||
3. Пока `/v1` существует, старый и новый UI остаются изолированными. Не тратить
|
4. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и
|
||||||
время на исправления, тесты и документацию, которые нужны только v1.
|
smoke-сценариями production-потребителей.
|
||||||
4. Удаление v1 — реальный следующий cleanup-задача (Q3). Не ждать, пока legacy
|
5. Изменения делаются небольшими атомарными частями, в соответствии с
|
||||||
начнёт мешать, и не тратить время на его поддержку; если задача упирается в
|
|
||||||
v1, Q3 выполняется перед ней.
|
|
||||||
5. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и
|
|
||||||
smoke-сценариями для обеих веток, пока ветка v1 не удалена.
|
|
||||||
6. Изменения делаются небольшими атомарными частями, в соответствии с
|
|
||||||
`AGENTS.md`.
|
`AGENTS.md`.
|
||||||
7. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме,
|
6. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме,
|
||||||
получает fixtures и тесты, и только после очистки baseline становится
|
получает fixtures и тесты, и только после очистки baseline становится
|
||||||
blocking.
|
blocking.
|
||||||
8. Детали реализации правил остаются в `web/eslint-plugins/README.md`;
|
7. Детали реализации правил остаются в `web/eslint-plugins/README.md`;
|
||||||
`AGENTS.md` содержит краткие обязательные политики, понятные агенту без
|
`AGENTS.md` содержит краткие обязательные политики, понятные агенту без
|
||||||
дополнительных поисков, и ссылки на подробные документы.
|
дополнительных поисков, и ссылки на подробные документы.
|
||||||
|
|
||||||
### Политика legacy v1
|
### Политика удаления legacy v1
|
||||||
|
|
||||||
- v1 можно удалить; это разрешённый cleanup, а не обязательная работа перед
|
- Q3 — отдельная атомарная cleanup-задача; поддержка v1 не возвращается.
|
||||||
каждой задачей.
|
- Старые маршруты и UI-файлы удаляются без redirects и без совместимого
|
||||||
- Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты без
|
maintenance-режима.
|
||||||
прямой зависимости текущей задачи от legacy-кода.
|
- В рабочей кодовой базе не оставлять UI-v1-пути, исключения или документацию,
|
||||||
- Если задача требует изменений в v1 или общих модулях, сначала выполнить Q3,
|
которые выглядят как действующий источник совместимости.
|
||||||
если это не противоречит атомарности текущей задачи, и продолжить работу
|
- Исторические упоминания допустимы только в `docs/archive/`.
|
||||||
только с production-веткой.
|
- Будущий API-контракт `POST /v1/process` из `docs/plan-platform.md` не связан с
|
||||||
- Если порядок работ изменится, Q3 можно перенести как целый cleanup, но не
|
удалённым UI-v1 и остаётся в планах.
|
||||||
возвращать постоянную поддержку v1.
|
|
||||||
- После удаления проверить маршруты, импорты, i18n, тесты, документацию и
|
|
||||||
конфигурацию lint; старые ссылки не оставлять как «источник совместимости».
|
|
||||||
|
|
||||||
## 2. Что уже есть и что мешает
|
## 2. Текущее состояние quality-контура
|
||||||
|
|
||||||
- `i18n/dict-consistency` реализован и подключён как `warn-only`
|
- `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование,
|
||||||
(`web/eslint.config.js:177-188`). Проверки ключей, пустых значений и
|
docs и production build; deploy выполняет его перед сборкой.
|
||||||
placeholders есть, но текущий baseline ещё содержит расхождения.
|
- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt —
|
||||||
- `lint:all` запускает ESLint, Stylelint и token audit
|
4 unused design tokens от token audit.
|
||||||
(`web/scripts/lint-all.mjs:31-54`), но не запускает typecheck, unit-тесты,
|
- В e2e остаются фиксированные числа, точные тексты, CSS-классы и
|
||||||
Prettier или Playwright.
|
`alt`-селекторы: `web/e2e/catalog.spec.ts:4-55`,
|
||||||
- CI workflow сейчас проверяет только production build
|
`web/e2e/text-and-verdicts.spec.ts:52-101`.
|
||||||
(`.github/workflows/deploy.yml:40-53`).
|
|
||||||
- В e2e есть фиксированные числа, точные тексты, CSS-классы и `alt`-селекторы:
|
|
||||||
`web/e2e/catalog.spec.ts:4-55`, `web/e2e/text-and-verdicts.spec.ts:52-101`.
|
|
||||||
- Backlog и реализация расходятся по генераторам: продуктовая заметка требует
|
- Backlog и реализация расходятся по генераторам: продуктовая заметка требует
|
||||||
автоматический запуск без `Generate`-кнопки (`docs/backlog.md:115-122`), а
|
автоматический запуск без `Generate`-кнопки (`docs/backlog.md:115-122`), а
|
||||||
тест ожидает кнопку (`web/e2e/generators.spec.ts:8-16`).
|
тест ожидает кнопку (`web/e2e/generators.spec.ts:8-16`).
|
||||||
@@ -137,9 +126,10 @@ pnpm --dir web test:e2e
|
|||||||
- `BASE_LOCALE` — единственный источник registry-English fallback для
|
- `BASE_LOCALE` — единственный источник registry-English fallback для
|
||||||
`tools.*.title` и `tools.*.description`; остальные локали требуют физические
|
`tools.*.title` и `tools.*.description`; остальные локали требуют физические
|
||||||
ключи.
|
ключи.
|
||||||
- Legacy `tools.*.params` исключён pattern-based policy до отдельной очистки
|
- Временно неиспользуемые `tools.*.params` исключены pattern-based policy до
|
||||||
мёртвого словаря; пустые значения и placeholders не подавляются.
|
отдельной очистки мёртвого словаря; пустые значения и placeholders не
|
||||||
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые legacy
|
подавляются.
|
||||||
|
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые
|
||||||
option-группы из `ru.ts`.
|
option-группы из `ru.ts`.
|
||||||
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается
|
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается
|
||||||
`BASE_LOCALE`.
|
`BASE_LOCALE`.
|
||||||
@@ -148,22 +138,21 @@ pnpm --dir web test:e2e
|
|||||||
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим
|
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим
|
||||||
этапом.
|
этапом.
|
||||||
|
|
||||||
### Q3. Удалить v1 (следующий cleanup)
|
### Q3. Удалить UI-v1 (cleanup завершён)
|
||||||
|
|
||||||
**Статус:** следующий шаг после Q2. **Оценка:** M. **Зависимости:** Q0, Q1, Q2.
|
**Статус:** выполнен. **Проверено:** `pnpm verify` и production E2E smoke
|
||||||
|
проходят; после проверки обновлены конфиги и активные документы. **Оценка:** M.
|
||||||
|
**Зависимости:** Q0, Q1, Q2.
|
||||||
|
|
||||||
- Удалить `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific
|
- Удалены `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific
|
||||||
i18n/tests и оставшиеся v1-ссылки.
|
i18n/tests и fixtures.
|
||||||
- Убрать v1 из isolation/lint/test-конфигураций и документации; не оставлять
|
- Удалены isolation plugin, его тест и все v1-исключения из lint-конфигураций.
|
||||||
исключения только ради удалённого кода.
|
- Убраны v1-ссылки из production-кода и активных документов; исторические
|
||||||
- Обновить shared `core`/`theme` проверки: после удаления v1 проверять только
|
упоминания остаются в `docs/archive/`.
|
||||||
production-потребителя.
|
- Проверены production build, verify и отдельный E2E smoke production-маршрутов.
|
||||||
- Прогнать `pnpm verify` и отдельный e2e smoke production-маршрутов.
|
|
||||||
- Не выполнять удаление как побочный эффект другого PR; это отдельная атомарная
|
|
||||||
задача.
|
|
||||||
|
|
||||||
**Готово, когда:** production build и verify зелёные, v1-маршруты и файлы
|
**Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют;
|
||||||
отсутствуют, а ссылки и конфиги не содержат legacy-путей.
|
следующий этап — Q4.
|
||||||
|
|
||||||
### Q4. Тестовый контракт e2e
|
### Q4. Тестовый контракт e2e
|
||||||
|
|
||||||
@@ -243,8 +232,6 @@ file-result-инструментам.
|
|||||||
- Очистить или использовать unused design tokens; после очистки решить, должен
|
- Очистить или использовать unused design tokens; после очистки решить, должен
|
||||||
ли unused audit быть fail-режимом.
|
ли unused audit быть fail-режимом.
|
||||||
- Добавить тесты для token audit и PostCSS HCT-плагина.
|
- Добавить тесты для token audit и PostCSS HCT-плагина.
|
||||||
- Проверить/исправить расхождение документации isolation rule и реально
|
|
||||||
передаваемых options.
|
|
||||||
- Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов.
|
- Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов.
|
||||||
- Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и
|
- Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и
|
||||||
hardcoded colors наравне со Svelte styles.
|
hardcoded colors наравне со Svelte styles.
|
||||||
@@ -291,8 +278,8 @@ Blanket-запрет всех string literals не использовать.
|
|||||||
|
|
||||||
1. Какой набор команд считается полным quality gate и что означает успешный
|
1. Какой набор команд считается полным quality gate и что означает успешный
|
||||||
результат при warnings.
|
результат при warnings.
|
||||||
2. Старый/new scope и политику v1: v1 можно удалить, но пока он существует, не
|
2. Production-scope и политику удаления v1: новые изменения относятся только к
|
||||||
расширять на него новые правила и не тратить время на его поддержку.
|
production UI; исторические UI-v1-упоминания остаются в архиве.
|
||||||
3. Политику e2e: semantic locators, допустимый `data-testid`, запрет
|
3. Политику e2e: semantic locators, допустимый `data-testid`, запрет
|
||||||
бессмысленных counts/classes/alt/exact prose.
|
бессмысленных counts/classes/alt/exact prose.
|
||||||
4. Политику i18n: новые пользовательские тексты через i18n; registry source,
|
4. Политику i18n: новые пользовательские тексты через i18n; registry source,
|
||||||
@@ -301,7 +288,7 @@ Blanket-запрет всех string literals не использовать.
|
|||||||
исправление и удаление теста происходят вместе.
|
исправление и удаление теста происходят вместе.
|
||||||
6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на
|
6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на
|
||||||
работу.
|
работу.
|
||||||
7. Shared-модули `core`/`theme` требуют проверки обеих веток.
|
7. Shared-модули `core`/`theme` требуют проверки production-потребителей.
|
||||||
|
|
||||||
Синтаксические списки ESLint-правил и подробные токены остаются в
|
Синтаксические списки ESLint-правил и подробные токены остаются в
|
||||||
`web/eslint-plugins/README.md`.
|
`web/eslint-plugins/README.md`.
|
||||||
@@ -309,7 +296,7 @@ Blanket-запрет всех string literals не использовать.
|
|||||||
### Политика документации для агентов
|
### Политика документации для агентов
|
||||||
|
|
||||||
`AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем
|
`AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем
|
||||||
только обязательный рабочий контракт: команды, границы old/new, ключевые
|
только обязательный рабочий контракт: команды, production-scope, ключевые
|
||||||
инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять
|
инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять
|
||||||
причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка;
|
причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка;
|
||||||
длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в
|
длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в
|
||||||
@@ -351,15 +338,15 @@ history или пересоздают как новый активный док
|
|||||||
|
|
||||||
### Какие документы подготовить
|
### Какие документы подготовить
|
||||||
|
|
||||||
| Документ | Назначение | Когда создавать/обновлять |
|
| Документ | Назначение | Когда создавать/обновлять |
|
||||||
| ------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
| ------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||||
| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа |
|
| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа |
|
||||||
| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка, работа с legacy и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` |
|
| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` |
|
||||||
| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI |
|
| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI |
|
||||||
| `docs/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры |
|
| `docs/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры |
|
||||||
| `docs/architecture-overview.md` | Карта `core`, registry, executor, нового UI и `/v1`, shared-модули и зависимости | Q5 или перед крупным рефакторингом; не дублировать существующие планы |
|
| `docs/architecture-overview.md` | Карта `core`, registry, executor, production UI и shared-модулей | Q5 или перед крупным рефакторингом; не дублировать существующие планы |
|
||||||
| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами |
|
| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами |
|
||||||
| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики |
|
| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики |
|
||||||
|
|
||||||
Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию
|
Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию
|
||||||
backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и
|
backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и
|
||||||
@@ -392,11 +379,9 @@ refactor, новые i18n-правила и исправление продук
|
|||||||
|
|
||||||
## 7. Решения, которые нужно принять на ревью
|
## 7. Решения, которые нужно принять на ревью
|
||||||
|
|
||||||
- Оставляем ли dictionary linter warn-only до очистки или сразу вводим blocking
|
- Какие semantic locators и допустимые `data-testid` закрепить в Q4?
|
||||||
baseline?
|
|
||||||
- Считаем ли `registry.title/description` физическими i18n-ключами или
|
|
||||||
допустимым английским fallback?
|
|
||||||
- Подтверждаем ли auto-run генераторов без кнопки Generate?
|
|
||||||
- Что именно должен делать Reset при включённом auto-run?
|
|
||||||
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
|
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
|
||||||
отдельным ночным прогоном?
|
отдельным ночным прогоном?
|
||||||
|
- Подтверждаем ли auto-run генераторов без кнопки Generate?
|
||||||
|
- Что именно должен делать Reset при включённом auto-run?
|
||||||
|
- Какие i18n-сценарии проверяются в e2e, а какие остаются unit-проверками?
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
|
||||||
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
CLI и API (harness фазы 7 расширяется третьим рантаймом).
|
||||||
|
|
||||||
## 2. Фаза 10 — PWA (после C17 и S1; C19 не блокер)
|
## 2. Фаза 10 — PWA (после S1)
|
||||||
|
|
||||||
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
|
||||||
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
maskable, theme-color из дизайн-токенов, `display: standalone`.
|
||||||
@@ -64,8 +64,7 @@
|
|||||||
## 3. Порядок и связи
|
## 3. Порядок и связи
|
||||||
|
|
||||||
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
|
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8 (не дублируются).
|
||||||
- PWA — после C17 (один дизайн, реальные маршруты) и после S1 `plan-seo.md`; C19
|
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
|
||||||
отложен и PWA не ждёт (не кешировать noindex-версии); API — после фаз 5–7.
|
|
||||||
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
|
||||||
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
аргументы для Product Hunt / alternative.to / GitHub (plan-seo S4c) и для
|
||||||
GEO-текстов (`llms.txt`, описания категорий).
|
GEO-текстов (`llms.txt`, описания категорий).
|
||||||
|
|||||||
+50
-116
@@ -1,44 +1,26 @@
|
|||||||
# План: переезд на новый дизайн (refs) — параллельная сборка
|
# План: переезд на новый дизайн (refs) — параллельная сборка
|
||||||
|
|
||||||
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**.
|
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**, Q3
|
||||||
> Остались C20 (шаг 6, проверка нового дизайна) — **следующий этап**. C19/C21
|
> (удаление старого UI) **выполнен**. Остался C20 (шаг 6, проверка нового
|
||||||
> (шаг 7, удаление старого) — **отложены**, нет ценности. Они больше не
|
> дизайна) — **следующий этап**. C21 (удалить refs) остаётся отдельной уборкой.
|
||||||
> привязаны к «верности против рефа»: `docs/archive/plan-design-fix.md` закрыт,
|
> Общий tech-debt baseline и следующие quality-задачи зафиксированы в
|
||||||
> аудит против рефа прекращён. Общий tech-debt baseline зафиксирован в
|
> `docs/plan-codebase-quality.md`. Покупка домена НЕ была блокером C17: домен
|
||||||
> `docs/plan-codebase-quality.md`; legacy cleanup остаётся отложенным. Backlog
|
> нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`.
|
||||||
> №14 (переезд старых файлов в `old/`) — **выполнено**. Покупка домена НЕ была
|
|
||||||
> блокером C17: домен нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие
|
|
||||||
> `noindex`.
|
|
||||||
>
|
>
|
||||||
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
|
||||||
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
|
||||||
>
|
>
|
||||||
> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка
|
> **Историческая стратегия перехода:** сначала новый UI собирался отдельно,
|
||||||
> общих компонентов на месте с алиасами токенов) — **параллельная сборка**:
|
> затем стал production, после чего legacy-ветка была удалена Q3. Текущие
|
||||||
> новый дизайн строится отдельно, изолируется от старого, проверяется целиком,
|
> критерии относятся только к production UI; старые решения и детали перехода
|
||||||
> затем становится основным и старый удаляется. Старое приложение остаётся
|
> остаются в архивных планах.
|
||||||
> рабочим и шippable на всём протяжении перехода.
|
|
||||||
|
|
||||||
## 0. Почему меняем стратегию
|
## 0. Почему меняем стратегию
|
||||||
|
|
||||||
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые токены
|
- Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить
|
||||||
как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие компоненты
|
независимо; теперь production использует только новые маршруты и `app.css`.
|
||||||
редактируются вживую → регресс старого UI во время работы, новый дизайн нельзя
|
- Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout
|
||||||
принять изолированно.
|
для удалённого UI-v1 больше не нужны.
|
||||||
- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые страницы
|
|
||||||
рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем его основным,
|
|
||||||
(5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги.
|
|
||||||
- **Главный технический риск и его решение.** Сейчас `web/src/app.css`
|
|
||||||
импортируется в **корневом** `web/src/routes/+layout.svelte:2` и потому
|
|
||||||
грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать новый
|
|
||||||
CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без
|
|
||||||
наследования**: (а) убрать `import '../app.css'` из корневого layout; (б)
|
|
||||||
старые маршруты обернуть группой `(old)/` с собственным layout, импортирующим
|
|
||||||
`app.css`; (в) новые маршруты (`preview/*`) — собственный layout,
|
|
||||||
импортирующий `design2.css`. SvelteKit делает code-splitting CSS по маршрутам,
|
|
||||||
а стили компонентов скоупятся сами — поэтому глобальные стили каждого дизайна
|
|
||||||
живут только в своей ветке, и префикс-обёртка `.ds2` не нужна. Старые `ui/` и
|
|
||||||
страницы не трогаются до фазы 5.
|
|
||||||
|
|
||||||
## 1. Источники и их оценка
|
## 1. Источники и их оценка
|
||||||
|
|
||||||
@@ -189,68 +171,29 @@
|
|||||||
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
|
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
|
||||||
> пригодится для сброса родительского layout.
|
> пригодится для сброса родительского layout.
|
||||||
|
|
||||||
## 6. Маршрутизация параллельных страниц
|
## 6. Маршрутизация: историческая схема перехода
|
||||||
|
|
||||||
Новые страницы живут в **отдельной ветке `preview/*`**, не трогая старые:
|
Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были
|
||||||
|
перенесены на production-маршруты. Эта схема нужна только для восстановления
|
||||||
|
контекста C17; текущая структура описана в разделе 7, отдельных preview- или
|
||||||
|
legacy-layouts в production больше нет.
|
||||||
|
|
||||||
```txt
|
## 7. Фазы выполнения
|
||||||
src/routes/+layout.svelte # нейтральный, БЕЗ app.css
|
|
||||||
src/routes/(old)/+layout.svelte # импорт app.css + старая шапка/подвал
|
|
||||||
src/routes/(old)/+page.svelte # / (старое)
|
|
||||||
src/routes/(old)/demo/+page.svelte # /demo
|
|
||||||
src/routes/(old)/list-tools/...
|
|
||||||
src/routes/(old)/tools/[id]/...
|
|
||||||
src/routes/preview/+layout.svelte # импорт design2.css, <AppShell>
|
|
||||||
src/routes/preview/+page.svelte # /preview — воркспейс (index/demo)
|
|
||||||
src/routes/preview/demo/+page.svelte # /preview/demo
|
|
||||||
src/routes/preview/list-tools/+page.svelte
|
|
||||||
src/routes/preview/tools/[id]/+page.svelte
|
|
||||||
```
|
|
||||||
|
|
||||||
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы не
|
- **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и
|
||||||
изменяются (только перемещены в `(old)/`).
|
интерактивные состояния приняты; детали перехода остаются в архивном
|
||||||
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты (move-файлы),
|
`plan-design-fix.md`.
|
||||||
группа `(old)/` удаляется, `design2.css` подключается в корневом
|
- **C17/C18 — production-переход.** Новые страницы перенесены на реальные
|
||||||
`+layout.svelte` (как новый основной слой), временный флаг/кнопка убираются.
|
корневые маршруты; `app.css` стал единственным production-стилем.
|
||||||
|
- **Q3/C19 — cleanup.** Удалены UI-v1, legacy CSS, v1-тесты, fixtures и
|
||||||
|
isolation-конфигурация; production build и verify проверяются отдельно.
|
||||||
|
- **C20 — следующий этап.** Финальный проход по брейкпоинтам и визуальной
|
||||||
|
приёмке production UI.
|
||||||
|
- **C21 — отдельная уборка.** Удалить `refs/` и `refs-html/`, если они больше не
|
||||||
|
нужны.
|
||||||
|
|
||||||
## 7. Фазы выполнения (1–5 по запросу)
|
Критерии каждого этапа — зелёные `test`/`check`/`build`, отсутствие остаточных
|
||||||
|
legacy-путей в production-коде и ревью в браузере для UI-изменений.
|
||||||
Каждая фаза заканчивается зелёными `test`/`check`/`build` и ревью в браузере
|
|
||||||
(обе темы) против `refs-html/`.
|
|
||||||
|
|
||||||
- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все примитивы
|
|
||||||
§3 и контролы §4 как отдельные компоненты. Критерий: демо-страница kit'а
|
|
||||||
(`/preview` или временная) показывает все состояния (light/dark,
|
|
||||||
hover/disabled, все варианты Button/Segmented/Badge); unit-тесты форм не
|
|
||||||
падают; **старые страницы визуально не изменились** (изоляция работает).
|
|
||||||
|
|
||||||
- **Фаза 2 — Новые страницы.** Под `preview/*` (§6): воркспейс, demo,
|
|
||||||
list-tools, tool-page (двухпанельная раскладка из рефа: settings-panel +
|
|
||||||
preview-panel, step-cards для chain). Критерий: разметка соответствует
|
|
||||||
`refs-html/index.html`, `gradient.html`, `background-remover.html`,
|
|
||||||
`list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает в
|
|
||||||
новом стиле; старые маршруты продолжают работать как раньше.
|
|
||||||
|
|
||||||
- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран, 800px),
|
|
||||||
скриншоты против `refs-html/`, прогон старого тест-сьюта для доказательства
|
|
||||||
отсутствия регресса, RU/EN-локализация новых лейблов. Критерий: визуальное
|
|
||||||
совпадение с рефом, зелёные тесты, записанные скриншоты приняты.
|
|
||||||
|
|
||||||
- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*` → реальные
|
|
||||||
маршруты; `design2.css` подключается в корневом `+layout.svelte`; удаление
|
|
||||||
preview-группы и временного флага/кнопки. Старые страницы теперь не
|
|
||||||
используются (мёртвый код), но физически ещё в репо. Критерий: реальные
|
|
||||||
маршруты отдают новый дизайн, тесты зелёные, билд без предупреждений.
|
|
||||||
|
|
||||||
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого `ui/`,
|
|
||||||
старого `app.css` (и его токенов), неиспользуемых классов; наведение порядка в
|
|
||||||
`components/` (имя каталога и подпапок — решается на фазе 1; к C17 `kit/` уже
|
|
||||||
разложен в `components/`). Финальный проход по брейкпоинтам 1200/1100/800/480.
|
|
||||||
Критерий: `grep` по старым токенам/классам пуст, build чистый, старый дизайн
|
|
||||||
нигде не упоминается.
|
|
||||||
|
|
||||||
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы (~1–2 дня),
|
|
||||||
Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка (~полдня).
|
|
||||||
|
|
||||||
## 8. Смоук (после фазы 3, контроль на фазе 4)
|
## 8. Смоук (после фазы 3, контроль на фазе 4)
|
||||||
|
|
||||||
@@ -268,14 +211,10 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
## 9. Открытые вопросы
|
## 9. Открытые вопросы
|
||||||
|
|
||||||
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
|
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
|
||||||
разложил его в `components/` (подпапки fields/layout/ui). На C19 — только
|
разложил его в `components/` (подпапки fields/layout/ui). На C21 — только
|
||||||
навести порядок внутри `components/`, переименование не требуется.
|
навести порядок внутри `components/`, переименование не требуется.
|
||||||
- **Изоляция через layout:** `app.css` вынесен из корневого layout в
|
- **Единый production CSS:** `app.css` подключается в корневом layout; отдельных
|
||||||
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
|
legacy-layouts и переключателей версий больше нет.
|
||||||
Проверить на фазе 1, что на `/preview/*` не попадают стили старого дизайна
|
|
||||||
(DevTools → вкладка Styles, нет правил из `app.css`).
|
|
||||||
- **Переключатель для проверки:** рекомендую `localStorage`-флаг + кнопка
|
|
||||||
«Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
|
|
||||||
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
|
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
|
||||||
ключи, но допустить часть английских брендовых («приборная панель»).
|
ключи, но допустить часть английских брендовых («приборная панель»).
|
||||||
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
|
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
|
||||||
@@ -283,8 +222,8 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
|
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
|
||||||
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
|
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
|
||||||
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
|
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
|
||||||
не нужен и не создаётся. `refs/` и `refs-html/` можно удалить из репо на фазе
|
не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка;
|
||||||
5 (по желанию), они не являются частью рантайма.
|
их можно удалить в C21, они не являются частью рантайма.
|
||||||
|
|
||||||
## 10. План исполнения (мелкие коммиты)
|
## 10. План исполнения (мелкие коммиты)
|
||||||
|
|
||||||
@@ -362,10 +301,10 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
|
|
||||||
### Шаг 5. Сделать новый дизайн основным
|
### Шаг 5. Сделать новый дизайн основным
|
||||||
|
|
||||||
> C17 — **выполнен** (`docs/archive/plan-c17.md`): старый `(old)/` → `/v1/`,
|
> C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты
|
||||||
> preview-страницы переехали в корень, `app.css` стал корневым. C18 (правки
|
> перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов
|
||||||
> импортов/редиректов после переноса, проверка билда) — **выполнен**. Остались:
|
> после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и
|
||||||
> C19 (удаление старого, раскрытие `recommended` на весь код), C20, C21.
|
> код. Остались C20 и C21.
|
||||||
|
|
||||||
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
|
||||||
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
|
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
|
||||||
@@ -387,26 +326,21 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
|
|
||||||
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без
|
||||||
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
|
предупреждений; `grep` по старым токенам/классам пуст. Доказательство
|
||||||
отсутствия регресса: старый `v1/` работает и не затронут.
|
отсутствия регресса: production-маршруты и основные пользовательские
|
||||||
|
потоки работают.
|
||||||
|
|
||||||
### Шаг 7. Опциональная чистка (отложен, нет срока)
|
### Шаг 7. Cleanup
|
||||||
|
|
||||||
|
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
||||||
|
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
|
||||||
|
из конфигурации и активной документации.
|
||||||
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
|
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник
|
||||||
правды.
|
правды.
|
||||||
- [ ] **[C19]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
|
|
||||||
`app_v1.css` (и его токены), неиспользуемые классы. **И раскрыть ESLint
|
|
||||||
`recommended` на весь код** — убрать scoped-блок `newCode` в
|
|
||||||
`web/eslint.config.js`, прогнать `lint` и починить всплывшие ошибки.
|
|
||||||
_Примечание: доменные модули (`lib/core`, `lib/registry`, тесты и т.п.) —
|
|
||||||
это не «старый дизайн», у них свой линт-долг; расширение `recommended` на
|
|
||||||
них может выдать много ошибок. Решить на C19: либо чиним сразу, либо
|
|
||||||
расширяем scoped только на `src/routes/**` + `src/lib/components/**`._
|
|
||||||
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
|
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
|
||||||
каталога).
|
каталога).
|
||||||
|
|
||||||
> **Удаление v1 не решает никаких текущих задач** и не даёт пользовательской
|
> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
|
||||||
> ценности. Старый код лежит мёртвым, не мешает, не ломает билд. Удалять стоит
|
> После удаления проверяются только production-маршруты и общие модули.
|
||||||
> только когда нет горящих задач и хочется навести порядок.
|
|
||||||
>
|
>
|
||||||
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
|
||||||
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
|
||||||
|
|||||||
+9
-9
@@ -138,7 +138,7 @@
|
|||||||
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
|
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
|
||||||
**domain-required** (после покупки и DNS-настройки).
|
**domain-required** (после покупки и DNS-настройки).
|
||||||
|
|
||||||
#### S1-free: без домена (параллельно с C18–C19)
|
#### S1-free: без домена (параллельно с quality-планом)
|
||||||
|
|
||||||
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
|
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
|
||||||
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
|
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть
|
для lossy) для всех инструментов — механика `ToolEntry.output` уже есть
|
||||||
(backlog №7, райз «выбор формата/качества»). Продуктовая основа «image
|
(backlog №7, райз «выбор формата/качества»). Продуктовая основа «image
|
||||||
tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
|
tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
|
||||||
параллельно с C18–C19.
|
параллельно с текущими quality-задачами.
|
||||||
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
|
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
|
||||||
формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в
|
формат исходника (disable) — чтобы «convert png to jpg» не мог сохранить в
|
||||||
png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`,
|
png. Существующие страницы не трогать: `jpg-to-png`, `png-to-jpg`,
|
||||||
@@ -166,8 +166,8 @@
|
|||||||
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в
|
страниц. Вызов из `pnpm build` после `vite build` (adapter-static пишет в
|
||||||
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`),
|
`build/`, дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`),
|
||||||
учёт опционального `BASE_PATH`.
|
учёт опционального `BASE_PATH`.
|
||||||
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебные маршруты (`/kit` до
|
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
|
||||||
C19; после — вернуть минимальный allow-all).
|
cleanup — вернуть минимальный allow-all.
|
||||||
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
|
||||||
актуальному маршруту), OG-теги (title/description/type/url/image),
|
актуальному маршруту), OG-теги (title/description/type/url/image),
|
||||||
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
|
> зелёный; в `build/` лежит корректный `sitemap.xml` со всеми инструментами;
|
||||||
> canonical/OG присутствуют на всех страницах; e2e зелёные.
|
> canonical/OG присутствуют на всех страницах; e2e зелёные.
|
||||||
|
|
||||||
### S2. Контентный слой (параллельно C18–C19, волнами по категориям)
|
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
|
||||||
|
|
||||||
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
|
||||||
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
|
||||||
@@ -328,10 +328,10 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
|
|||||||
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
|
||||||
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
настроить деплой на Vercel без `BASE_PATH` (§0). Домен нужен до S1-domain:
|
||||||
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
|
||||||
- **Порядок:** R1+R2 можно делать параллельно с C17/C18/C19 (не блокируют). R2
|
- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
|
||||||
(домен) — блокер только для S1-domain (canonical + sitemap + снятие
|
блокируют). R2 (домен) — блокер только для S1-domain (canonical + sitemap +
|
||||||
`noindex`). После индексации переименование = переезд с 301, потеря позиций и
|
снятие `noindex`). После индексации переименование = переезд с 301, потеря
|
||||||
ссылок — не делать. Поэтому R1/R2 — до S1-domain, но не обязательно до C17.
|
позиций и ссылок — не делать.
|
||||||
|
|
||||||
## 7. Что осознанно НЕ делаем
|
## 7. Что осознанно НЕ делаем
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ baseline; после закрытия известных ошибок его м
|
|||||||
- Token audit пока оставляет unused-token warnings отдельным неблокирующим
|
- Token audit пока оставляет unused-token warnings отдельным неблокирующим
|
||||||
сообщением.
|
сообщением.
|
||||||
- `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным
|
- `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным
|
||||||
предупреждением token audit; это следующий debt Q6.
|
предупреждением token audit; это следующий debt Q7.
|
||||||
- `dict-consistency` использует base-locale fallback для registry metadata и
|
- `dict-consistency` использует base-locale fallback для registry metadata и
|
||||||
pattern-based policy для legacy `tools.*.params`; это не молчаливый
|
pattern-based policy для временно неиспользуемых `tools.*.params`; это не
|
||||||
`allowPaths`-список.
|
молчаливый `allowPaths`-список.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
|
||||||
> каталог переведён в типизированный `registry` (122 текущих инструмента), новый
|
> каталог переведён в типизированный `registry` (122 текущих инструмента), новый
|
||||||
> дизайн по `plan-redesign.md` стал основным на корневых маршрутах (C17/C18
|
> дизайн по `plan-redesign.md` стал единственным production UI на корневых
|
||||||
> выполнены), старый UI — на `/v1/*` (удаление отложено, ценности не даёт — C19
|
> маршрутах; UI-v1 удалён в Q3. Tech debt не закрыт полностью; текущий baseline
|
||||||
> в бэклоге). Tech debt не закрыт полностью; текущий baseline и следующие
|
> и следующие quality-задачи зафиксированы в `docs/plan-codebase-quality.md`.
|
||||||
> quality-задачи зафиксированы в `docs/plan-codebase-quality.md`. Следующий
|
> Следующий этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO
|
||||||
> этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO S1-free
|
> S1-free (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`,
|
||||||
> (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`, S1-domain). Фазы
|
> S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в
|
||||||
> 2–8 — будущие. Фазы 9–10 спланированы в `docs/plan-platform.md`.
|
> `docs/plan-platform.md`.
|
||||||
|
|
||||||
## 0. Решения
|
## 0. Решения
|
||||||
|
|
||||||
@@ -162,8 +162,8 @@ wasm.
|
|||||||
### Фаза 10 — PWA — см. `docs/plan-platform.md`
|
### Фаза 10 — PWA — см. `docs/plan-platform.md`
|
||||||
|
|
||||||
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
|
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
|
||||||
устройство»), установка как приложение. Wasm-фаз не требует, идёт после C17/C19
|
устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из
|
||||||
и S1 из `plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
|
`plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
|
||||||
|
|
||||||
## 3. Правила сравнения (для фаз 4–7)
|
## 3. Правила сравнения (для фаз 4–7)
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@
|
|||||||
|
|
||||||
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
||||||
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
||||||
- `isolation/` — правило изоляции old/new UI.
|
|
||||||
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
||||||
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
||||||
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
||||||
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
||||||
токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`.
|
токенов) и мини-словари `lib/i18n/`.
|
||||||
- `web/scripts/`:
|
- `web/scripts/`:
|
||||||
- `lint-all.mjs` — оркестратор `lint:all`.
|
- `lint-all.mjs` — оркестратор `lint:all`.
|
||||||
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
||||||
@@ -28,16 +27,9 @@
|
|||||||
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
|
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
|
||||||
`prefer: 'type-imports'`).
|
`prefer: 'type-imports'`).
|
||||||
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
|
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
|
||||||
`eslint-plugin-svelte`) навешаны **только на новый код**
|
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
|
||||||
(`src/lib/components/**`, `src/routes/**`), чтобы старый код не засыпался
|
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
|
||||||
предсуществующими ошибками. Старый код (C17) — `src/lib/v1/**`,
|
устаревшие пути в `eslint.config.js`.
|
||||||
`src/routes/v1/**`. Когда старый дизайн удалён (C19), scoped-блок убирается и
|
|
||||||
`recommended` включается на весь код.
|
|
||||||
- **Scoped-пути двигаются вместе с папками** (см. `plan-redesign.md` §10): не
|
|
||||||
оставлять устаревшие пути в `eslint.config.js` (C17 уже двигал
|
|
||||||
`routes/preview/**` → `routes/**` и `components/kit/**` → `components/**`; на
|
|
||||||
C19 при наведении порядка в `components/` paths останутся `components/**` +
|
|
||||||
`routes/**`).
|
|
||||||
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
|
||||||
`let`).
|
`let`).
|
||||||
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
|
||||||
@@ -45,7 +37,6 @@
|
|||||||
`__fixtures__/` дописать их туда же.
|
`__fixtures__/` дописать их туда же.
|
||||||
- design-tokens правила применяются к тем же scoped-путям
|
- design-tokens правила применяются к тем же scoped-путям
|
||||||
(`src/lib/components/**`, `src/routes/**`).
|
(`src/lib/components/**`, `src/routes/**`).
|
||||||
- isolation-правило включено на весь `**/*.{ts,svelte}`.
|
|
||||||
|
|
||||||
## Правила плагина design-tokens
|
## Правила плагина design-tokens
|
||||||
|
|
||||||
@@ -66,10 +57,11 @@
|
|||||||
|
|
||||||
## Правило плагина i18n (`i18n/dict-consistency`)
|
## Правило плагина i18n (`i18n/dict-consistency`)
|
||||||
|
|
||||||
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md`
|
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
|
||||||
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только
|
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
|
||||||
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый
|
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
|
||||||
словарь подхватывается без правки правила.
|
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
|
||||||
|
правила.
|
||||||
|
|
||||||
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
||||||
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
||||||
@@ -92,9 +84,9 @@
|
|||||||
отсутствовать только в базовой локали; в текущей конфигурации это registry
|
отсутствовать только в базовой локали; в текущей конфигурации это registry
|
||||||
metadata `tools.*.title` и `tools.*.description`. Пустые значения и
|
metadata `tools.*.title` и `tools.*.description`. Пустые значения и
|
||||||
placeholders для присутствующих ключей всё равно проверяются.
|
placeholders для присутствующих ключей всё равно проверяются.
|
||||||
- `ignoreMissingPatterns: string[]` — паттерны legacy-ключей, которые временно
|
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
|
||||||
не проверяются на missing; в текущей конфигурации это старый `tools.*.params`,
|
проверяются на missing; в текущей конфигурации это неиспользуемые
|
||||||
не используемый новым UI. Эта опция не отключает empty/placeholder checks.
|
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
|
||||||
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||||
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||||
@@ -150,9 +142,6 @@ hct-авторство.
|
|||||||
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
|
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
|
||||||
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
|
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
|
||||||
`__fixtures__/src/app.css` (не из реального `src/app.css`).
|
`__fixtures__/src/app.css` (не из реального `src/app.css`).
|
||||||
- `__tests__/no-mixed-imports.test.ts` — isolation-правило через `Linter` API с
|
|
||||||
`cwd` на `__fixtures__`: правило резолвит импорты по реальным файлам, поэтому
|
|
||||||
цели импортов обязаны существовать на диске.
|
|
||||||
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
|
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
|
||||||
`process.cwd()` не трогается, реальные `src/` не читаются.
|
`process.cwd()` не трогается, реальные `src/` не читаются.
|
||||||
|
|
||||||
@@ -161,27 +150,3 @@ hct-авторство.
|
|||||||
|
|
||||||
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
|
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
|
||||||
добавление/правка стабов — тоже работа с валидным кодом.
|
добавление/правка стабов — тоже работа с валидным кодом.
|
||||||
|
|
||||||
## Isolation-правило (`isolation/no-mixed-imports`)
|
|
||||||
|
|
||||||
**Полная взаимная изоляция** старого (v1) и нового (корень) UI. В отличие от
|
|
||||||
`no-restricted-imports`, правило **резолвит** каждый импорт (и `$lib/...`, и
|
|
||||||
относительные `./`/`../`) до реального файла и классифицирует стороны по
|
|
||||||
фактическому пути, поэтому относительным импортом правило не обойти.
|
|
||||||
|
|
||||||
- Старое: `routes/v1/**`, `lib/v1/**`.
|
|
||||||
- Новое: `routes/**` без `v1/**`; корневой `lib` целиком (`lib/**`) без
|
|
||||||
`lib/v1/**` и без общих пакетов.
|
|
||||||
- Общее (разрешено обоим) — только служебные пакеты: `core/`, `theme`. v1 не
|
|
||||||
высовывается: из `lib/v1/**` наружу разрешены импорты лишь в общие пакеты и в
|
|
||||||
собственный форк i18n (`lib/v1/i18n/**`).
|
|
||||||
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
|
|
||||||
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка
|
|
||||||
glob-паттернов в настройке, а не код правила.
|
|
||||||
- Изоляция уже достигнута: старый `lib/v1/registry.ts`/`registry-helpers.ts` не
|
|
||||||
тянут `registry-schema` (пилоты add-border/add-stroke работают через
|
|
||||||
`params`), `registry.ts` не импортирует `ToolSchema`; новый `list-tools`
|
|
||||||
использует `$lib/tool-icons.ts`, а не старые `lib/v1/tools/tool-icons.ts`.
|
|
||||||
|
|
||||||
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
|
|
||||||
`app_v1.css`) — известный техдолг, его НЕ чинить и не игнорировать правилами.
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Fixture stub for lint-rule tests: content is ignored (the test supplies the
|
|
||||||
// source code), this file only has to EXIST for import resolution.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
|
|
||||||
@@ -3,11 +3,10 @@
|
|||||||
// Two strategies, picked per rule in the test files:
|
// Two strategies, picked per rule in the test files:
|
||||||
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
|
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
|
||||||
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
|
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
|
||||||
// - Linter API + fixtures — needed when a rule reads real files. The isolation
|
// - Linter API + fixtures — needed when a rule reads real files, such as
|
||||||
// rule resolves imports via fs (targets must exist on disk) and
|
// no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
|
||||||
// no-undefined-in-svelte reads <cwd>/src/app.css for the token dictionary.
|
// dictionary. `verifyInFixtures` runs a rule with cwd fixed to
|
||||||
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
|
// `__fixtures__/`, without touching process.cwd() or the real src/ tree.
|
||||||
// work without touching process.cwd() or the real src/ tree.
|
|
||||||
import { Linter } from "eslint";
|
import { Linter } from "eslint";
|
||||||
import type { Rule } from "eslint";
|
import type { Rule } from "eslint";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
// Tests for the isolation rule (web/eslint-plugins/isolation/no-mixed-imports.js).
|
|
||||||
//
|
|
||||||
// The rule RESOLVES import specifiers to real files (via $lib alias and
|
|
||||||
// relative ../ paths), so every import target must exist on disk. It therefore
|
|
||||||
// runs through the Linter API with cwd pinned to the committed fixture tree
|
|
||||||
// (__fixtures__/src) — see __tests__/helpers.ts.
|
|
||||||
//
|
|
||||||
// Side map (mirrors the defaults in no-mixed-imports.js):
|
|
||||||
// old -> routes/v1/**, lib/v1/**
|
|
||||||
// new -> routes/** (minus v1), lib/** (minus lib/v1/** and shared)
|
|
||||||
// shared-> everything else: core/, theme
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import noMixedImports from "../isolation/no-mixed-imports.js";
|
|
||||||
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
|
|
||||||
|
|
||||||
const isolationConfig: FlatConfig = [
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,svelte}"],
|
|
||||||
plugins: {
|
|
||||||
isolation: {
|
|
||||||
rules: { "no-mixed-imports": asRuleModule(noMixedImports) },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: { "isolation/no-mixed-imports": "error" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const messagesFor = (relFile: string, code: string) =>
|
|
||||||
verifyInFixtures(isolationConfig, code, relFile);
|
|
||||||
|
|
||||||
/** Assert a single "noMixed" violation with the given sides. */
|
|
||||||
function expectViolation(
|
|
||||||
relFile: string,
|
|
||||||
code: string,
|
|
||||||
side: string,
|
|
||||||
target: string,
|
|
||||||
) {
|
|
||||||
const messages = messagesFor(relFile, code);
|
|
||||||
expect(messages).toHaveLength(1);
|
|
||||||
expect(messages[0].messageId).toBe("noMixed");
|
|
||||||
// ESLint interpolates `data` into the message text (no `data` field on the
|
|
||||||
// returned message), so assert on the rendered message instead.
|
|
||||||
expect(messages[0].message).toContain(`"${side}" file imports "${target}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assert the import is allowed (no violations). */
|
|
||||||
function expectClean(relFile: string, code: string) {
|
|
||||||
expect(messagesFor(relFile, code)).toEqual([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("isolation/no-mixed-imports", () => {
|
|
||||||
it("flags an old file importing a new component", () => {
|
|
||||||
expectViolation(
|
|
||||||
"lib/v1/old.ts",
|
|
||||||
'import { c } from "../components/CheckerCanvas.svelte";',
|
|
||||||
"old",
|
|
||||||
"new",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a new route importing an old module through the $lib alias", () => {
|
|
||||||
expectViolation(
|
|
||||||
"routes/+page.svelte",
|
|
||||||
'import { o } from "$lib/v1/old";',
|
|
||||||
"new",
|
|
||||||
"old",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a new component importing old code via a relative path", () => {
|
|
||||||
expectViolation(
|
|
||||||
"lib/components/CheckerCanvas.svelte",
|
|
||||||
'import { o } from "../v1/old.ts";',
|
|
||||||
"new",
|
|
||||||
"old",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows old -> shared (core via relative path)", () => {
|
|
||||||
expectClean("lib/v1/old.ts", 'import { e } from "../core/errors";');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags old -> root i18n ($lib/i18n is the new branch now)", () => {
|
|
||||||
expectViolation(
|
|
||||||
"routes/v1/+layout.svelte",
|
|
||||||
'import { t } from "$lib/i18n/t";',
|
|
||||||
"old",
|
|
||||||
"new",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows old -> own i18n copy and theme via $lib", () => {
|
|
||||||
expectClean(
|
|
||||||
"routes/v1/+layout.svelte",
|
|
||||||
'import { t } from "$lib/v1/i18n/t";',
|
|
||||||
);
|
|
||||||
expectClean(
|
|
||||||
"routes/v1/+layout.svelte",
|
|
||||||
'import { g } from "$lib/theme.svelte";',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows new -> new (root i18n and components)", () => {
|
|
||||||
expectClean("routes/+page.svelte", 'import { t } from "$lib/i18n/t";');
|
|
||||||
expectClean(
|
|
||||||
"routes/+page.svelte",
|
|
||||||
'import { b } from "$lib/components/ui/Button.svelte";',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags new i18n importing old and leaves core shared", () => {
|
|
||||||
expectViolation(
|
|
||||||
"lib/i18n/t.ts",
|
|
||||||
'import { o } from "$lib/v1/old";',
|
|
||||||
"new",
|
|
||||||
"old",
|
|
||||||
);
|
|
||||||
expectClean(
|
|
||||||
"lib/core/errors.ts",
|
|
||||||
'import { c } from "$lib/components/CheckerCanvas.svelte";',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the old i18n copy on the old side", () => {
|
|
||||||
expectClean("lib/v1/i18n/t.ts", 'import { o } from "$lib/v1/old";');
|
|
||||||
expectViolation(
|
|
||||||
"lib/v1/i18n/t.ts",
|
|
||||||
'import { c } from "$lib/components/CheckerCanvas.svelte";',
|
|
||||||
"old",
|
|
||||||
"new",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Local ESLint plugin "isolation".
|
|
||||||
*
|
|
||||||
* Guarantees full isolation between the old (v1, archived) UI branch and the
|
|
||||||
* new one. 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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* Isolation rule: forbid mixing the "old" (v1) and the "new" 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/, 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";
|
|
||||||
|
|
||||||
// NEW declared as an allowlist: `lib/**` descends into v1/ and the shared
|
|
||||||
// packages (core/, theme), so negations carve them back out; `routes/**`
|
|
||||||
// needs `!routes/v1/**` for the old branch. Last-match-wins
|
|
||||||
// (see matchesGlobList).
|
|
||||||
const DEFAULT_OLD = ["routes/v1/**", "lib/v1/**"];
|
|
||||||
|
|
||||||
const DEFAULT_NEW = [
|
|
||||||
"routes/**",
|
|
||||||
"!routes/v1/**",
|
|
||||||
"lib/**",
|
|
||||||
"!lib/v1/**",
|
|
||||||
"!lib/core/**",
|
|
||||||
"!lib/theme.svelte.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 (v1) UI branch and the new 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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+1
-36
@@ -5,16 +5,11 @@ 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";
|
||||||
import designTokens from "./eslint-plugins/index.js";
|
import designTokens from "./eslint-plugins/index.js";
|
||||||
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
|
||||||
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
||||||
import i18nPlugin from "./eslint-plugins/i18n/index.js";
|
import i18nPlugin from "./eslint-plugins/i18n/index.js";
|
||||||
|
|
||||||
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
|
||||||
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
||||||
// Когда старый дизайн удалим (C19), этот scoped-блок убирается и recommended
|
|
||||||
// включается на весь код (см. план-redesign §10, шаг 6).
|
|
||||||
const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
|
const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
|
||||||
const oldCode = ["**/src/lib/v1/**", "**/src/routes/v1/**"];
|
|
||||||
|
|
||||||
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
|
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
|
||||||
const jsRecommended = Array.isArray(js.configs.recommended)
|
const jsRecommended = Array.isArray(js.configs.recommended)
|
||||||
@@ -29,10 +24,6 @@ const newSvelteFiles = [
|
|||||||
"**/src/lib/components/**/*.svelte",
|
"**/src/lib/components/**/*.svelte",
|
||||||
"**/src/routes/**/*.svelte",
|
"**/src/routes/**/*.svelte",
|
||||||
];
|
];
|
||||||
const oldSvelteFiles = [
|
|
||||||
"**/src/lib/v1/**/*.svelte",
|
|
||||||
"**/src/routes/v1/**/*.svelte",
|
|
||||||
];
|
|
||||||
|
|
||||||
// FIXME:
|
// FIXME:
|
||||||
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
|
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
|
||||||
@@ -82,21 +73,17 @@ export default tseslint.config(
|
|||||||
"eslint-plugins/__tests__/interface-props.test.ts",
|
"eslint-plugins/__tests__/interface-props.test.ts",
|
||||||
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
||||||
"eslint-plugins/__tests__/helpers.ts",
|
"eslint-plugins/__tests__/helpers.ts",
|
||||||
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
|
|
||||||
"eslint-plugins/__tests__/dict-consistency.test.ts",
|
"eslint-plugins/__tests__/dict-consistency.test.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/de.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/de.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
|
|
||||||
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/i18n/t.ts",
|
"eslint-plugins/__fixtures__/src/lib/i18n/t.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
|
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte",
|
"eslint-plugins/__fixtures__/src/lib/components/CheckerCanvas.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte",
|
"eslint-plugins/__fixtures__/src/lib/components/ui/Button.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
|
|
||||||
],
|
],
|
||||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
||||||
},
|
},
|
||||||
@@ -134,11 +121,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
|
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
|
||||||
// Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн
|
// Применяется к новому коду редизайна.
|
||||||
// удалят, расширить glob на весь код, исключив (old)/.
|
|
||||||
{
|
{
|
||||||
files: newSvelteFiles,
|
files: newSvelteFiles,
|
||||||
ignores: oldSvelteFiles,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
"design-tokens": designTokens,
|
"design-tokens": designTokens,
|
||||||
},
|
},
|
||||||
@@ -151,10 +136,8 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
|
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
|
||||||
// `let {...}: Props = $props()` (плагин conventions/interface-props).
|
// `let {...}: Props = $props()` (плагин conventions/interface-props).
|
||||||
// Только Svelte-файлы нового кода (см. newSvelteFiles).
|
|
||||||
{
|
{
|
||||||
files: newSvelteFiles,
|
files: newSvelteFiles,
|
||||||
ignores: oldSvelteFiles,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
conventions: conventionsPlugin,
|
conventions: conventionsPlugin,
|
||||||
},
|
},
|
||||||
@@ -166,7 +149,6 @@ export default tseslint.config(
|
|||||||
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
|
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
|
||||||
{
|
{
|
||||||
files: newCode,
|
files: newCode,
|
||||||
ignores: oldCode,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
conventions: conventionsPlugin,
|
conventions: conventionsPlugin,
|
||||||
},
|
},
|
||||||
@@ -198,17 +180,14 @@ export default tseslint.config(
|
|||||||
...jsRecommended.map((cfg) => ({
|
...jsRecommended.map((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
files: newCode,
|
files: newCode,
|
||||||
ignores: oldCode,
|
|
||||||
})),
|
})),
|
||||||
...tseslint.configs.recommended.map((cfg) => ({
|
...tseslint.configs.recommended.map((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
files: newCode,
|
files: newCode,
|
||||||
ignores: oldCode,
|
|
||||||
})),
|
})),
|
||||||
...svelteRecommended.map((cfg) => ({
|
...svelteRecommended.map((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
files: newSvelteFiles,
|
files: newSvelteFiles,
|
||||||
ignores: oldSvelteFiles,
|
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -220,20 +199,6 @@ 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/, theme) разрешено обоим.
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,svelte}"],
|
|
||||||
plugins: {
|
|
||||||
isolation: isolationPlugin,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"isolation/no-mixed-imports": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// prettier — последним, чтобы гасить форматирующие правила из recommended.
|
// prettier — последним, чтобы гасить форматирующие правила из recommended.
|
||||||
prettier,
|
prettier,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
@layer app {
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
|
|
||||||
--bg: #f6f7f9;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--text: #1b1e24;
|
|
||||||
--text-muted: #5c6470;
|
|
||||||
--accent: #2563eb;
|
|
||||||
--accent-hover: #1d4ed8;
|
|
||||||
--accent-contrast: #ffffff;
|
|
||||||
--link: var(--accent);
|
|
||||||
--border: #dfe2e8;
|
|
||||||
--danger: #e5484d;
|
|
||||||
--danger-strong: #b3261e;
|
|
||||||
--check-a: #eceff3;
|
|
||||||
--check-b: #ffffff;
|
|
||||||
|
|
||||||
--space-1: 0.25rem;
|
|
||||||
--space-2: 0.5rem;
|
|
||||||
--space-3: 1rem;
|
|
||||||
--space-4: 1.5rem;
|
|
||||||
--space-5: 2.5rem;
|
|
||||||
|
|
||||||
--radius-s: 6px;
|
|
||||||
--radius-m: 10px;
|
|
||||||
|
|
||||||
--text-xs: 0.8rem;
|
|
||||||
--text-s: 0.85rem;
|
|
||||||
--text-m: 0.95rem;
|
|
||||||
--text-l: 1.1rem;
|
|
||||||
--text-xl: 1.25rem;
|
|
||||||
--text-xxl: 1.75rem;
|
|
||||||
--leading-tight: 1.25;
|
|
||||||
|
|
||||||
--shadow-card: 0 4px 14px rgb(37 99 235 / 12%);
|
|
||||||
--transition-fast: 120ms ease;
|
|
||||||
|
|
||||||
--control-height: 2.4rem;
|
|
||||||
--control-max-width: 16rem;
|
|
||||||
|
|
||||||
--font-sans:
|
|
||||||
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
|
||||||
sans-serif;
|
|
||||||
--font-mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
|
|
||||||
--bg: #12151a;
|
|
||||||
--surface: #1b2027;
|
|
||||||
--text: #e6e9ee;
|
|
||||||
--text-muted: #98a1ad;
|
|
||||||
--accent-hover: #4c85f0;
|
|
||||||
--link: #6ea0ff;
|
|
||||||
--border: #303842;
|
|
||||||
--danger: #ff7479;
|
|
||||||
--danger-strong: #ff8589;
|
|
||||||
--check-a: #232830;
|
|
||||||
--check-b: #171b21;
|
|
||||||
|
|
||||||
--shadow-card: 0 4px 14px rgb(0 0 0 / 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
line-height: 1.55;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: var(--text-xxl);
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
label {
|
|
||||||
font-size: var(--text-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--link);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-caption {
|
|
||||||
font-size: var(--text-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading-section {
|
|
||||||
font-size: var(--text-l);
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--surface);
|
|
||||||
max-width: var(--control-max-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.control[type="number"] {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
|
|
||||||
color: var(--danger-strong);
|
|
||||||
font-size: var(--text-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0 0 0 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-stage {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
align-items: start;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-stage.single {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-stage .cell {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-stage .cell + .cell {
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
padding-left: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-media {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 14rem;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chain-stack {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edge-legend {
|
|
||||||
position: absolute;
|
|
||||||
top: -0.65em;
|
|
||||||
padding: 0 var(--space-2);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
line-height: 1.3;
|
|
||||||
white-space: nowrap;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.params-sep {
|
|
||||||
position: relative;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 0 var(--space-4);
|
|
||||||
padding-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Панель этапа для инлайн-раскладки: исходник | параметры | результат */
|
|
||||||
.pane {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-legend {
|
|
||||||
top: -0.65em;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-1);
|
|
||||||
align-items: stretch;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row > button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
|
||||||
.tool-stage {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-stage .cell + .cell {
|
|
||||||
border-left: none;
|
|
||||||
padding-left: 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,6 @@
|
|||||||
*
|
*
|
||||||
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
|
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
|
||||||
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
|
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
|
||||||
* Этот файл — локальная копия подхода для нового UI; старый
|
|
||||||
* `lib/v1/categories` (массив + union вручную) не трогаем — он обслуживает
|
|
||||||
* старый UI в `/v1/*`.
|
|
||||||
*
|
*
|
||||||
* Человекочитаемые названия живут в словарях i18n: секция categories,
|
* Человекочитаемые названия живут в словарях i18n: секция categories,
|
||||||
* ключ = CategoryId.
|
* ключ = CategoryId.
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
// Новая типизированная схема параметров (для нового UI, старый UI не трогаем).
|
// Типизированная схема параметров для нового UI.
|
||||||
//
|
|
||||||
// Параллельный источник правды к старому `ParamDef[]`: старый pipeline
|
|
||||||
// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/
|
|
||||||
// валидацию поверх `ToolSchema<P>`.
|
|
||||||
//
|
//
|
||||||
// Связка типов на этапе компиляции (защита от рассинхрона):
|
// Связка типов на этапе компиляции (защита от рассинхрона):
|
||||||
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
|
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export type CategoryId =
|
|
||||||
| "convert"
|
|
||||||
| "alpha"
|
|
||||||
| "color"
|
|
||||||
| "geometry"
|
|
||||||
| "analyze"
|
|
||||||
| "generate"
|
|
||||||
| "filters"
|
|
||||||
| "text";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
|
||||||
* i18n: секция categories, ключ = CategoryId.
|
|
||||||
*/
|
|
||||||
export const CATEGORIES: readonly CategoryId[] = [
|
|
||||||
"convert",
|
|
||||||
"alpha",
|
|
||||||
"color",
|
|
||||||
"geometry",
|
|
||||||
"filters",
|
|
||||||
"text",
|
|
||||||
"analyze",
|
|
||||||
"generate",
|
|
||||||
];
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { downloadBlob, encode } from "$lib/core/io";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import type { OutputFormat } from "$lib/v1/registry";
|
|
||||||
import Button from "./ui/Button.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
image: PixelImage | null;
|
|
||||||
format?: OutputFormat;
|
|
||||||
baseName: string;
|
|
||||||
params: Record<string, unknown>;
|
|
||||||
onError?: (e: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { image, format, baseName, params, onError }: Props = $props();
|
|
||||||
|
|
||||||
let busy = $state(false);
|
|
||||||
|
|
||||||
async function download() {
|
|
||||||
if (!image || !format || busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
const raw = format.qualityParamId
|
|
||||||
? params[format.qualityParamId]
|
|
||||||
: undefined;
|
|
||||||
const quality =
|
|
||||||
typeof raw === "number"
|
|
||||||
? Math.min(Math.max(raw, 1), 100) / 100
|
|
||||||
: undefined;
|
|
||||||
const blob = await encode(image, format.mime, quality);
|
|
||||||
downloadBlob(blob, `${baseName}.${format.ext}`);
|
|
||||||
} catch (e) {
|
|
||||||
onError?.(e);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
fullWidth
|
|
||||||
disabled={!image || !format}
|
|
||||||
{busy}
|
|
||||||
busyText={t("download.busy")}
|
|
||||||
onclick={download}
|
|
||||||
>
|
|
||||||
{t("download.file", { ext: format?.ext ?? "png" })}
|
|
||||||
</Button>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import { isSupportedImage, unsupportedImageError } from "$lib/core/io";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onFile: (file: File) => void;
|
|
||||||
onError?: (e: unknown) => void;
|
|
||||||
label?: string;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
onFile,
|
|
||||||
onError,
|
|
||||||
label = t("dropZone.overlayDefault"),
|
|
||||||
children,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let depth = $state(0);
|
|
||||||
const dragging = $derived(depth > 0);
|
|
||||||
|
|
||||||
function enter() {
|
|
||||||
depth += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function leave() {
|
|
||||||
depth = Math.max(0, depth - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function over(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function end() {
|
|
||||||
depth = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drop(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
depth = 0;
|
|
||||||
const file = event.dataTransfer?.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!isSupportedImage(file)) {
|
|
||||||
onError?.(unsupportedImageError(file));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onFile(file);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="area"
|
|
||||||
class:dragging
|
|
||||||
role="region"
|
|
||||||
aria-label={label}
|
|
||||||
ondragenter={enter}
|
|
||||||
ondragleave={leave}
|
|
||||||
ondragover={over}
|
|
||||||
ondragend={end}
|
|
||||||
ondrop={drop}
|
|
||||||
>
|
|
||||||
{@render children()}
|
|
||||||
{#if dragging}
|
|
||||||
<div class="veil" aria-hidden="true">
|
|
||||||
<p>{label}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.area {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.veil {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
||||||
border: 2px dashed var(--accent);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
color: var(--link);
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
ACCEPTED_IMAGE_TYPES,
|
|
||||||
isSupportedImage,
|
|
||||||
unsupportedImageError,
|
|
||||||
} from "$lib/core/io";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onFile: (file: File) => void;
|
|
||||||
onError?: (e: unknown) => void;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onFile, onError, label = t("dropZone.pickDefault") }: Props = $props();
|
|
||||||
|
|
||||||
let input = $state<HTMLInputElement | undefined>();
|
|
||||||
let depth = $state(0);
|
|
||||||
const dragging = $derived(depth > 0);
|
|
||||||
|
|
||||||
function enter() {
|
|
||||||
depth += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function leave() {
|
|
||||||
depth = Math.max(0, depth - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function over(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function end() {
|
|
||||||
depth = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function accept(file: File | undefined | null) {
|
|
||||||
if (!file) return;
|
|
||||||
if (!isSupportedImage(file)) {
|
|
||||||
onError?.(unsupportedImageError(file));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPicker() {
|
|
||||||
input?.click();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="dropzone panel"
|
|
||||||
class:dragging
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label={label}
|
|
||||||
onclick={openPicker}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
openPicker();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
ondragenter={enter}
|
|
||||||
ondragleave={leave}
|
|
||||||
ondragover={over}
|
|
||||||
ondragend={end}
|
|
||||||
ondrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
depth = 0;
|
|
||||||
accept(e.dataTransfer?.files[0]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" class="icon">⬇</span>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
bind:this={input}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPTED_IMAGE_TYPES}
|
|
||||||
hidden
|
|
||||||
onchange={() => {
|
|
||||||
accept(input?.files?.[0]);
|
|
||||||
if (input) {
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dropzone {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-height: 14rem;
|
|
||||||
padding: var(--space-4);
|
|
||||||
border-style: dashed;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition:
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropzone:hover,
|
|
||||||
.dropzone:focus-visible,
|
|
||||||
.dragging {
|
|
||||||
border-color: var(--link);
|
|
||||||
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ImageInfo } from "$lib/core/analyze";
|
|
||||||
import { LOCALE_TAGS } from "$lib/v1/i18n/dict";
|
|
||||||
import { getLocale } from "$lib/v1/i18n/locale.svelte";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
info: ImageInfo | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { info }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if info}
|
|
||||||
<dl>
|
|
||||||
<div class="panel">
|
|
||||||
<dt>{t("infoPanel.dimensions")}</dt>
|
|
||||||
<dd>{info.width} × {info.height} px</dd>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<dt>{t("infoPanel.alpha")}</dt>
|
|
||||||
<dd>
|
|
||||||
{info.hasAlpha ? t("infoPanel.alphaYes") : t("infoPanel.alphaNo")}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="panel">
|
|
||||||
<dt>{t("infoPanel.colorCount")}</dt>
|
|
||||||
<dd>{info.colorCount.toLocaleString(LOCALE_TAGS[getLocale()])}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
dl {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin: var(--space-3) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dl > div {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { optionLabel, paramLabel } from "$lib/v1/i18n/tool-strings";
|
|
||||||
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
|
|
||||||
import CheckboxField from "./ui/CheckboxField.svelte";
|
|
||||||
import ColorField from "./ui/ColorField.svelte";
|
|
||||||
import SelectField from "./ui/SelectField.svelte";
|
|
||||||
import SliderField from "./ui/SliderField.svelte";
|
|
||||||
import TextField from "./ui/TextField.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tool: ToolEntry;
|
|
||||||
params: ParamDef[];
|
|
||||||
values: Record<string, any>;
|
|
||||||
pipetteTargetId?: string | null;
|
|
||||||
onPipetteToggle?: (id: string) => void;
|
|
||||||
hasMask?: boolean;
|
|
||||||
showMask?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
tool,
|
|
||||||
params,
|
|
||||||
values = $bindable(),
|
|
||||||
pipetteTargetId = null,
|
|
||||||
onPipetteToggle,
|
|
||||||
hasMask = false,
|
|
||||||
showMask = $bindable(false),
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="params-grid">
|
|
||||||
{#if hasMask}
|
|
||||||
<CheckboxField
|
|
||||||
id="show-mask"
|
|
||||||
label={t("ui.showMask")}
|
|
||||||
bind:checked={showMask}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#each params as param (param.id)}
|
|
||||||
<div class="field">
|
|
||||||
{#if param.type === "checkbox"}
|
|
||||||
<CheckboxField
|
|
||||||
id={param.id}
|
|
||||||
label={paramLabel(tool, param)}
|
|
||||||
bind:checked={values[param.id]}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "number"}
|
|
||||||
<TextField
|
|
||||||
id={param.id}
|
|
||||||
label={paramLabel(tool, param)}
|
|
||||||
type="number"
|
|
||||||
min={param.min}
|
|
||||||
max={param.max}
|
|
||||||
step={param.step}
|
|
||||||
bind:value={values[param.id]}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "slider"}
|
|
||||||
<SliderField
|
|
||||||
id={param.id}
|
|
||||||
label={paramLabel(tool, param)}
|
|
||||||
min={param.min}
|
|
||||||
max={param.max}
|
|
||||||
step={param.step}
|
|
||||||
default={param.default}
|
|
||||||
bind:value={values[param.id]}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "select"}
|
|
||||||
<SelectField
|
|
||||||
id={param.id}
|
|
||||||
label={paramLabel(tool, param)}
|
|
||||||
options={param.options.map((o) => ({
|
|
||||||
value: o.value,
|
|
||||||
label: optionLabel(tool, param, o.value),
|
|
||||||
}))}
|
|
||||||
bind:value={values[param.id]}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "color"}
|
|
||||||
<ColorField
|
|
||||||
id={param.id}
|
|
||||||
label={paramLabel(tool, param)}
|
|
||||||
bind:value={values[param.id]}
|
|
||||||
pipetteActive={pipetteTargetId === param.id}
|
|
||||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
|
||||||
/>
|
|
||||||
{:else if param.type === "text"}
|
|
||||||
<TextField
|
|
||||||
id={param.id}
|
|
||||||
label={paramLabel(tool, param)}
|
|
||||||
type="text"
|
|
||||||
placeholder={param.placeholder}
|
|
||||||
bind:value={values[param.id]}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.params-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
column-gap: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 75rem) {
|
|
||||||
.params-grid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 50rem) {
|
|
||||||
.params-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { rgbToHex } from "$lib/core/color";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import PipetteLoupe from "./tool/PipetteLoupe.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
image: PixelImage | null;
|
|
||||||
pipetteActive?: boolean;
|
|
||||||
onPickColor?: (hex: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
|
||||||
|
|
||||||
let canvas = $state<HTMLCanvasElement | undefined>();
|
|
||||||
let hover = $state<{
|
|
||||||
clientX: number;
|
|
||||||
clientY: number;
|
|
||||||
px: number;
|
|
||||||
py: number;
|
|
||||||
hex: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!canvas || !image) return;
|
|
||||||
canvas.width = image.width;
|
|
||||||
canvas.height = image.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
ctx.putImageData(
|
|
||||||
new ImageData(image.data, image.width, image.height),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function pixelAt(
|
|
||||||
event: MouseEvent,
|
|
||||||
): { px: number; py: number; hex: string } | null {
|
|
||||||
if (!canvas || !image) return null;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
if (rect.width === 0 || rect.height === 0) return null;
|
|
||||||
const px = Math.floor(
|
|
||||||
(event.clientX - rect.left) * (canvas.width / rect.width),
|
|
||||||
);
|
|
||||||
const py = Math.floor(
|
|
||||||
(event.clientY - rect.top) * (canvas.height / rect.height),
|
|
||||||
);
|
|
||||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height)
|
|
||||||
return null;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return null;
|
|
||||||
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
|
|
||||||
return { px, py, hex: rgbToHex(r, g, b) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickColor(event: MouseEvent) {
|
|
||||||
if (!pipetteActive || !onPickColor) return;
|
|
||||||
const pixel = pixelAt(event);
|
|
||||||
if (pixel) {
|
|
||||||
onPickColor(pixel.hex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackHover(event: MouseEvent) {
|
|
||||||
if (!pipetteActive) {
|
|
||||||
hover = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pixel = pixelAt(event);
|
|
||||||
hover = pixel
|
|
||||||
? {
|
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
|
||||||
px: pixel.px,
|
|
||||||
py: pixel.py,
|
|
||||||
hex: pixel.hex,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearHover() {
|
|
||||||
hover = null;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if image}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<canvas
|
|
||||||
bind:this={canvas}
|
|
||||||
title="{image.width} × {image.height}"
|
|
||||||
class:pipette={pipetteActive}
|
|
||||||
onclick={pickColor}
|
|
||||||
onmousemove={trackHover}
|
|
||||||
onmouseleave={clearHover}
|
|
||||||
></canvas>
|
|
||||||
<p class="dims">{image.width} × {image.height} px</p>
|
|
||||||
{#if pipetteActive && hover && image}
|
|
||||||
<PipetteLoupe
|
|
||||||
{image}
|
|
||||||
px={hover.px}
|
|
||||||
py={hover.py}
|
|
||||||
hex={hover.hex}
|
|
||||||
clientX={hover.clientX}
|
|
||||||
clientY={hover.clientY}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 32rem;
|
|
||||||
background: repeating-conic-gradient(
|
|
||||||
var(--check-a) 0% 25%,
|
|
||||||
var(--check-b) 0% 50%
|
|
||||||
);
|
|
||||||
background-size: 16px 16px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.pipette {
|
|
||||||
cursor: crosshair;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dims {
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-s);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,543 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { imageInfo, type ImageInfo } from "$lib/core/analyze";
|
|
||||||
import { ToolError } from "$lib/core/errors";
|
|
||||||
import {
|
|
||||||
decodeFile,
|
|
||||||
isSupportedImage,
|
|
||||||
unsupportedImageError,
|
|
||||||
} from "$lib/core/io";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
|
|
||||||
import {
|
|
||||||
defaultParams,
|
|
||||||
getTool,
|
|
||||||
sanitizeParams,
|
|
||||||
type ToolEntry,
|
|
||||||
} from "$lib/v1/registry";
|
|
||||||
import { createAutoRunner } from "$lib/v1/tools/auto-run";
|
|
||||||
import { executeStep } from "$lib/v1/tools/executor";
|
|
||||||
import { clearOverlay, setOverlay } from "$lib/v1/tools/overlay-store.svelte";
|
|
||||||
import {
|
|
||||||
loadStoredSteps,
|
|
||||||
newStepId,
|
|
||||||
saveSteps,
|
|
||||||
type PipelineStep,
|
|
||||||
} from "$lib/v1/tools/pipeline";
|
|
||||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
|
||||||
import ChainToolBlock from "./chain/ChainToolBlock.svelte";
|
|
||||||
import ToolSearch from "./search/ToolSearch.svelte";
|
|
||||||
import type { StageStatus } from "./stage/stage-props";
|
|
||||||
import ToolStage from "./stage/ToolStage.svelte";
|
|
||||||
import ToolStageClassic from "./stage/ToolStageClassic.svelte";
|
|
||||||
|
|
||||||
export type PresetStep = { toolId: string; values?: Record<string, unknown> };
|
|
||||||
|
|
||||||
let {
|
|
||||||
tool,
|
|
||||||
restoreChain = false,
|
|
||||||
stageVariant = "inline",
|
|
||||||
presetBaseValues,
|
|
||||||
presetChain,
|
|
||||||
}: {
|
|
||||||
tool: ToolEntry;
|
|
||||||
restoreChain?: boolean;
|
|
||||||
stageVariant?: "classic" | "inline";
|
|
||||||
presetBaseValues?: Record<string, unknown>;
|
|
||||||
presetChain?: PresetStep[];
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const StageComponent = $derived(
|
|
||||||
stageVariant === "classic" ? ToolStageClassic : ToolStage,
|
|
||||||
);
|
|
||||||
|
|
||||||
type Status = StageStatus;
|
|
||||||
|
|
||||||
const isPreset = $derived(
|
|
||||||
(presetChain?.length ?? 0) > 0 ||
|
|
||||||
Object.keys(presetBaseValues ?? {}).length > 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
let status = $state<Status>("idle");
|
|
||||||
let source = $state<PixelImage | null>(null);
|
|
||||||
let result = $state<PixelImage | null>(null);
|
|
||||||
let previewResult = $state<PixelImage | null>(null);
|
|
||||||
let textResult = $state<string | null>(null);
|
|
||||||
let showMask = $state(false);
|
|
||||||
let info = $state<ImageInfo | null>(null);
|
|
||||||
let errorText = $state("");
|
|
||||||
let overlayImage = $state<PixelImage | null>(null);
|
|
||||||
// svelte-ignore state_referenced_locally
|
|
||||||
let values = $state<Record<string, any>>({
|
|
||||||
...defaultParams(tool),
|
|
||||||
...presetBaseValues,
|
|
||||||
});
|
|
||||||
// svelte-ignore state_referenced_locally
|
|
||||||
let chain = $state<PipelineStep[]>(
|
|
||||||
restoreChain
|
|
||||||
? loadStoredSteps()
|
|
||||||
: (presetChain ?? []).flatMap((preset) => {
|
|
||||||
const stepTool = getTool(preset.toolId);
|
|
||||||
return stepTool
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: newStepId(),
|
|
||||||
toolId: preset.toolId,
|
|
||||||
values: { ...defaultParams(stepTool), ...preset.values },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let chainResults = $state<(PixelImage | null)[]>([]);
|
|
||||||
let lastRunChainJson = "";
|
|
||||||
|
|
||||||
const isInfo = $derived(tool.resultType === "info");
|
|
||||||
const isSourceless = $derived(tool.sourceMode === "none");
|
|
||||||
const isTextSource = $derived(tool.sourceMode === "text");
|
|
||||||
const sanitized = $derived(sanitizeParams(tool, values));
|
|
||||||
const canChainBase = $derived(
|
|
||||||
(tool.resultType ?? "image") === "image" && tool.sourceMode !== "text",
|
|
||||||
);
|
|
||||||
const hasMask = $derived(typeof tool.preview === "function");
|
|
||||||
const shownBase = $derived(
|
|
||||||
showMask && previewResult ? previewResult : result,
|
|
||||||
);
|
|
||||||
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ""));
|
|
||||||
|
|
||||||
function addChainStep() {
|
|
||||||
chain.push({ id: newStepId(), toolId: "", values: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeChainStep(index: number) {
|
|
||||||
chain.splice(index, 1);
|
|
||||||
chainResults = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeChain() {
|
|
||||||
chain.length = 0;
|
|
||||||
chainResults = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleChain() {
|
|
||||||
if (chain.length > 0) {
|
|
||||||
removeChain();
|
|
||||||
} else {
|
|
||||||
addChainStep();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyChainTool(index: number, toolId: string) {
|
|
||||||
const stepTool = getTool(toolId);
|
|
||||||
if (!stepTool || !chain[index]) return;
|
|
||||||
chain[index].toolId = toolId;
|
|
||||||
chain[index].values = defaultParams(stepTool);
|
|
||||||
}
|
|
||||||
|
|
||||||
const runner = createAutoRunner();
|
|
||||||
let hasLastRun = false;
|
|
||||||
let lastRunSource: PixelImage | null = null;
|
|
||||||
let lastRunValuesJson = "";
|
|
||||||
let pipetteTargetId = $state<string | null>(null);
|
|
||||||
|
|
||||||
function handlePipetteToggle(id: string) {
|
|
||||||
pipetteTargetId = pipetteTargetId === id ? null : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePickColor(hex: string) {
|
|
||||||
if (!pipetteTargetId) return;
|
|
||||||
values[pipetteTargetId] = hex;
|
|
||||||
pipetteTargetId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
|
||||||
errorText = "";
|
|
||||||
status = "processing";
|
|
||||||
try {
|
|
||||||
source = await decodeFile(file);
|
|
||||||
values = defaultParams(tool);
|
|
||||||
result = null;
|
|
||||||
previewResult = null;
|
|
||||||
showMask = false;
|
|
||||||
pipetteTargetId = null;
|
|
||||||
info = isInfo ? imageInfo(source) : null;
|
|
||||||
if (isInfo) {
|
|
||||||
status = "loaded";
|
|
||||||
} else {
|
|
||||||
await runTool();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTextSubmit(text: string) {
|
|
||||||
if (!isTextSource) return;
|
|
||||||
errorText = "";
|
|
||||||
status = "processing";
|
|
||||||
if (tool.textToText) {
|
|
||||||
try {
|
|
||||||
result = null;
|
|
||||||
previewResult = null;
|
|
||||||
info = null;
|
|
||||||
textResult = await tool.textToText(text);
|
|
||||||
status = "loaded";
|
|
||||||
} catch (e) {
|
|
||||||
showError(e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!tool.runFromText) return;
|
|
||||||
try {
|
|
||||||
source = await tool.runFromText(text, defaultParams(tool));
|
|
||||||
values = defaultParams(tool);
|
|
||||||
result = null;
|
|
||||||
textResult = null;
|
|
||||||
previewResult = null;
|
|
||||||
showMask = false;
|
|
||||||
pipetteTargetId = null;
|
|
||||||
info = null;
|
|
||||||
await runTool();
|
|
||||||
} catch (e) {
|
|
||||||
showError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTool() {
|
|
||||||
if (isInfo) return;
|
|
||||||
if (!source && !isSourceless) return;
|
|
||||||
const token = runner.next();
|
|
||||||
hasLastRun = true;
|
|
||||||
lastRunSource = source;
|
|
||||||
lastRunValuesJson = JSON.stringify(sanitized);
|
|
||||||
lastRunChainJson = JSON.stringify(chain);
|
|
||||||
try {
|
|
||||||
let next: PixelImage | null = null;
|
|
||||||
let nextText: string | null = null;
|
|
||||||
|
|
||||||
if (tool.resultType === "text") {
|
|
||||||
nextText = await tool.toText!(source!, sanitized);
|
|
||||||
} else if (isSourceless) {
|
|
||||||
next = await tool.generate!(sanitized);
|
|
||||||
} else {
|
|
||||||
next = await executeStep(tool, source!, sanitized);
|
|
||||||
}
|
|
||||||
let nextPreview: PixelImage | null = null;
|
|
||||||
if (tool.preview && source) {
|
|
||||||
try {
|
|
||||||
nextPreview = await tool.preview(source, sanitized);
|
|
||||||
} catch {
|
|
||||||
nextPreview = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!runner.isCurrent(token)) return;
|
|
||||||
result = next;
|
|
||||||
previewResult = nextPreview;
|
|
||||||
textResult = nextText;
|
|
||||||
|
|
||||||
const collected: (PixelImage | null)[] = [];
|
|
||||||
let current: PixelImage | null = next ?? source;
|
|
||||||
for (let i = 0; i < chain.length; i++) {
|
|
||||||
const step = chain[i];
|
|
||||||
const stepTool = step.toolId === "" ? undefined : getTool(step.toolId);
|
|
||||||
if (!current || !stepTool?.run) {
|
|
||||||
collected.push(null);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
current = await executeStep(
|
|
||||||
stepTool,
|
|
||||||
current,
|
|
||||||
sanitizeParams(stepTool, step.values),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
t("toolPage.stepError", {
|
|
||||||
n: i + 2,
|
|
||||||
title: toolTitle(stepTool),
|
|
||||||
msg: errorMessage(e),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!runner.isCurrent(token)) return;
|
|
||||||
collected.push(current);
|
|
||||||
}
|
|
||||||
chainResults = collected;
|
|
||||||
status = "loaded";
|
|
||||||
} catch (e) {
|
|
||||||
if (!runner.isCurrent(token)) return;
|
|
||||||
showError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const valuesJson = JSON.stringify(sanitized);
|
|
||||||
const chainJson = JSON.stringify(chain);
|
|
||||||
if (
|
|
||||||
hasLastRun &&
|
|
||||||
source === lastRunSource &&
|
|
||||||
valuesJson === lastRunValuesJson &&
|
|
||||||
chainJson === lastRunChainJson
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!source && !isSourceless) return;
|
|
||||||
if (isInfo) return;
|
|
||||||
return runner.schedule(() => void runTool());
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (isPreset) return;
|
|
||||||
const filled = chain.filter((step) => step.toolId !== "");
|
|
||||||
if (filled.length === 0 && !hasLastRun) return;
|
|
||||||
saveSteps(filled);
|
|
||||||
});
|
|
||||||
|
|
||||||
function errorMessage(e: unknown): string {
|
|
||||||
if (e instanceof ToolError) return t(e.key, e.vars);
|
|
||||||
return t(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(e: unknown) {
|
|
||||||
status = source ? "loaded" : "idle";
|
|
||||||
errorText = errorMessage(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
source = null;
|
|
||||||
result = null;
|
|
||||||
previewResult = null;
|
|
||||||
textResult = null;
|
|
||||||
showMask = false;
|
|
||||||
pipetteTargetId = null;
|
|
||||||
info = null;
|
|
||||||
errorText = "";
|
|
||||||
status = "idle";
|
|
||||||
clearOverlay();
|
|
||||||
values = { ...defaultParams(tool), ...presetBaseValues };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOverlayFile(file: File) {
|
|
||||||
try {
|
|
||||||
setOverlay(await decodeFile(file));
|
|
||||||
} catch (e) {
|
|
||||||
errorText = errorMessage(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaste(event: ClipboardEvent) {
|
|
||||||
const items = event.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item.type.startsWith("image/")) continue;
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!isSupportedImage(file)) {
|
|
||||||
errorText = errorMessage(unsupportedImageError(file));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleFile(file);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onpaste={handlePaste} />
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h1>{toolTitle(tool)}</h1>
|
|
||||||
<p class="description text-muted">{toolDescription(tool)}</p>
|
|
||||||
|
|
||||||
{#if errorText}
|
|
||||||
<div class="error-banner" role="alert">{errorText}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<StageComponent
|
|
||||||
mode="base"
|
|
||||||
{tool}
|
|
||||||
{source}
|
|
||||||
{result}
|
|
||||||
displayImage={shownBase}
|
|
||||||
{info}
|
|
||||||
{status}
|
|
||||||
{isInfo}
|
|
||||||
{isSourceless}
|
|
||||||
{isTextSource}
|
|
||||||
{textResult}
|
|
||||||
{sanitized}
|
|
||||||
{canChainBase}
|
|
||||||
hasChain={chain.length > 0}
|
|
||||||
{hasMask}
|
|
||||||
bind:showMask
|
|
||||||
bind:values
|
|
||||||
{pipetteTargetId}
|
|
||||||
{handleFile}
|
|
||||||
onTextSubmit={handleTextSubmit}
|
|
||||||
onSourceError={(e) => (errorText = errorMessage(e))}
|
|
||||||
{reset}
|
|
||||||
{toggleChain}
|
|
||||||
{handlePipetteToggle}
|
|
||||||
{handlePickColor}
|
|
||||||
{showError}
|
|
||||||
{errorMessage}
|
|
||||||
{overlayImage}
|
|
||||||
onOverlayFile={(f) => void handleOverlayFile(f)}
|
|
||||||
onOverlayError={(e) => (errorText = errorMessage(e))}
|
|
||||||
onOverlayClear={clearOverlay}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="chain-stack">
|
|
||||||
{#each chain as step, index (step.id)}
|
|
||||||
{#if step.toolId === ""}
|
|
||||||
<div class="panel empty-slot">
|
|
||||||
<header>
|
|
||||||
<h3 class="heading-section">
|
|
||||||
{t("toolPage.stepHeading", { n: index + 2 })}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="remove-step"
|
|
||||||
aria-label={t("toolPage.removeStepAria")}
|
|
||||||
onclick={() => removeChainStep(index)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<ToolSearch
|
|
||||||
onSelect={(id) => applyChainTool(index, id)}
|
|
||||||
chainableOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if getTool(step.toolId)}
|
|
||||||
{@const stepTool = getTool(step.toolId)!}
|
|
||||||
{#if stageVariant === "inline"}
|
|
||||||
{@const StepIcon = TOOL_ICONS[stepTool.id]}
|
|
||||||
<ToolStage
|
|
||||||
mode="chain"
|
|
||||||
{index}
|
|
||||||
tool={stepTool}
|
|
||||||
bind:values={step.values}
|
|
||||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
|
||||||
result={chainResults[index] ?? null}
|
|
||||||
busy={status === "processing"}
|
|
||||||
isLast={index === chain.length - 1}
|
|
||||||
onRemove={() => removeChainStep(index)}
|
|
||||||
onError={showError}
|
|
||||||
onAddStep={addChainStep}
|
|
||||||
onRemoveChain={removeChain}
|
|
||||||
>
|
|
||||||
{#snippet header()}
|
|
||||||
<span class="edge-legend step-legend">
|
|
||||||
{#if StepIcon}
|
|
||||||
<span class="step-icon" aria-hidden="true">
|
|
||||||
<StepIcon size={14} strokeWidth={2} />
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{t("chain.stepLabel", {
|
|
||||||
n: index + 2,
|
|
||||||
title: toolTitle(stepTool),
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="remove-step"
|
|
||||||
aria-label={t("toolPage.removeStepAria")}
|
|
||||||
title={t("toolPage.removeStepAria")}
|
|
||||||
onclick={() => removeChainStep(index)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</ToolStage>
|
|
||||||
{:else}
|
|
||||||
<ChainToolBlock
|
|
||||||
{index}
|
|
||||||
tool={stepTool}
|
|
||||||
bind:values={step.values}
|
|
||||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
|
||||||
result={chainResults[index] ?? null}
|
|
||||||
busy={status === "processing"}
|
|
||||||
isLast={index === chain.length - 1}
|
|
||||||
onRemove={() => removeChainStep(index)}
|
|
||||||
onError={showError}
|
|
||||||
onAddStep={addChainStep}
|
|
||||||
onRemoveChain={removeChain}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
h1 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
max-width: 48rem;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-legend {
|
|
||||||
left: var(--space-3);
|
|
||||||
top: -0.75em;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.4rem;
|
|
||||||
height: 1.4rem;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-slot {
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-slot header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-slot h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-step {
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-step:hover {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { toolTitle } from "$lib/v1/i18n/tool-strings";
|
|
||||||
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/v1/registry";
|
|
||||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
|
||||||
import DownloadButton from "../DownloadButton.svelte";
|
|
||||||
import Preview from "../Preview.svelte";
|
|
||||||
import ParamsCard from "../tool/ParamsCard.svelte";
|
|
||||||
import Button from "../ui/Button.svelte";
|
|
||||||
import EmptyState from "../ui/EmptyState.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
index: number;
|
|
||||||
tool: ToolEntry;
|
|
||||||
values: Record<string, any>;
|
|
||||||
input: PixelImage | null;
|
|
||||||
result: PixelImage | null;
|
|
||||||
busy: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
onRemove: () => void;
|
|
||||||
onError: (e: unknown) => void;
|
|
||||||
onAddStep: () => void;
|
|
||||||
onRemoveChain: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
index,
|
|
||||||
tool,
|
|
||||||
values = $bindable(),
|
|
||||||
input,
|
|
||||||
result,
|
|
||||||
busy,
|
|
||||||
isLast,
|
|
||||||
onRemove,
|
|
||||||
onError,
|
|
||||||
onAddStep,
|
|
||||||
onRemoveChain,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const format = $derived(outputOf(tool));
|
|
||||||
const safeParams = $derived(sanitizeParams(tool, values));
|
|
||||||
const StepIcon = $derived(TOOL_ICONS[tool.id]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="block">
|
|
||||||
<div class="panel tool-stage">
|
|
||||||
<span class="edge-legend step-legend">
|
|
||||||
{#if StepIcon}
|
|
||||||
<span class="step-icon" aria-hidden="true"
|
|
||||||
><StepIcon size={14} strokeWidth={2} /></span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{t("chain.stepLabel", { n: index + 2, title: toolTitle(tool) })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="remove"
|
|
||||||
aria-label={t("chain.removeStepAria")}
|
|
||||||
title={t("chain.removeStepAria")}
|
|
||||||
onclick={onRemove}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="cell">
|
|
||||||
<span class="edge-legend cell-legend" aria-hidden="true"
|
|
||||||
>{t("chain.inputLegend")}</span
|
|
||||||
>
|
|
||||||
<div class="cell-media">
|
|
||||||
<Preview image={input} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cell">
|
|
||||||
<span class="edge-legend cell-legend" aria-hidden="true"
|
|
||||||
>{t("chain.resultLegend")}</span
|
|
||||||
>
|
|
||||||
{#if busy && !result}
|
|
||||||
<div class="cell-media">
|
|
||||||
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="cell-media">
|
|
||||||
<Preview image={result} />
|
|
||||||
{#if busy}
|
|
||||||
<span class="recalc" aria-live="polite"
|
|
||||||
>{t("resultCard.recalc")}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="actions-row">
|
|
||||||
<DownloadButton
|
|
||||||
image={result}
|
|
||||||
{format}
|
|
||||||
baseName="{index + 2}-{tool.id}"
|
|
||||||
params={safeParams}
|
|
||||||
{onError}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
fullWidth
|
|
||||||
onclick={isLast ? onAddStep : onRemoveChain}
|
|
||||||
>
|
|
||||||
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if tool.params.length > 0}
|
|
||||||
<div class="params-sep">
|
|
||||||
<span class="edge-legend" aria-hidden="true"
|
|
||||||
>{t("chain.paramsLegend")}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<ParamsCard {tool} params={tool.params} bind:values />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-stage {
|
|
||||||
position: relative;
|
|
||||||
padding-top: var(--space-3);
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-legend {
|
|
||||||
left: var(--space-3);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.4rem;
|
|
||||||
height: 1.4rem;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove {
|
|
||||||
width: 1.4rem;
|
|
||||||
height: 1.4rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove:hover {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-legend {
|
|
||||||
top: -0.9em;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recalc {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--space-2);
|
|
||||||
right: var(--space-2);
|
|
||||||
padding: 2px var(--space-2);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
|
||||||
color: var(--link);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
toolId: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
selected?: boolean;
|
|
||||||
href?: string;
|
|
||||||
onActivate?: () => void;
|
|
||||||
onHover?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
toolId,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
selected = false,
|
|
||||||
href,
|
|
||||||
onActivate,
|
|
||||||
onHover,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const Icon = $derived(TOOL_ICONS[toolId]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet content()}
|
|
||||||
<span class="icon-box" aria-hidden="true">
|
|
||||||
{#if Icon}<Icon size={24} strokeWidth={1.75} />{/if}
|
|
||||||
</span>
|
|
||||||
<span class="text">
|
|
||||||
<span class="title">{title}</span>
|
|
||||||
<span class="hint text-caption text-muted">{description}</span>
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#if href}
|
|
||||||
<a {href} class="card" class:selected onmousemove={onHover}>
|
|
||||||
{@render content()}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="card"
|
|
||||||
class:selected
|
|
||||||
role="option"
|
|
||||||
aria-selected={selected}
|
|
||||||
tabindex="-1"
|
|
||||||
onclick={onActivate}
|
|
||||||
onmousemove={onHover}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter") onActivate?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{@render content()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--space-2);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover,
|
|
||||||
.card.selected {
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-box {
|
|
||||||
flex: none;
|
|
||||||
width: 2.6rem;
|
|
||||||
height: 2.6rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--text-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { LOCALE_TAGS } from "$lib/v1/i18n/dict";
|
|
||||||
import { getLocale } from "$lib/v1/i18n/locale.svelte";
|
|
||||||
import { normalizeForSearch, scoreDoc } from "$lib/v1/i18n/matching";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import {
|
|
||||||
toolDescription,
|
|
||||||
toolSearchDoc,
|
|
||||||
toolTitle,
|
|
||||||
} from "$lib/v1/i18n/tool-strings";
|
|
||||||
import { isChainable, TOOLS } from "$lib/v1/registry";
|
|
||||||
import ToolCard from "./ToolCard.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelect: (toolId: string) => void;
|
|
||||||
/** true — только инструменты, пригодные в шаг цепочки (слот добавления шага). */
|
|
||||||
chainableOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onSelect, chainableOnly = false }: Props = $props();
|
|
||||||
|
|
||||||
const candidates = $derived(
|
|
||||||
chainableOnly ? TOOLS.filter(isChainable) : TOOLS,
|
|
||||||
);
|
|
||||||
|
|
||||||
let query = $state("");
|
|
||||||
let activeIndex = $state(0);
|
|
||||||
let listOpen = $state(false);
|
|
||||||
|
|
||||||
type Match = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
popularity: number;
|
|
||||||
score: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const matches = $derived.by(() => {
|
|
||||||
const q = normalizeForSearch(query.trim());
|
|
||||||
const found: Match[] = [];
|
|
||||||
for (const tool of candidates) {
|
|
||||||
const s = scoreDoc(toolSearchDoc(tool), q);
|
|
||||||
if (s !== null && s > 0) {
|
|
||||||
found.push({
|
|
||||||
id: tool.id,
|
|
||||||
title: toolTitle(tool),
|
|
||||||
description: toolDescription(tool),
|
|
||||||
popularity: tool.popularity ?? 50,
|
|
||||||
score: s,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const collator = new Intl.Collator(LOCALE_TAGS[getLocale()]);
|
|
||||||
found.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.score - a.score ||
|
|
||||||
b.popularity - a.popularity ||
|
|
||||||
collator.compare(a.title, b.title),
|
|
||||||
);
|
|
||||||
return found.slice(0, 12);
|
|
||||||
});
|
|
||||||
|
|
||||||
function choose(id: string) {
|
|
||||||
listOpen = false;
|
|
||||||
query = "";
|
|
||||||
activeIndex = 0;
|
|
||||||
onSelect(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
|
||||||
if (!listOpen || matches.length === 0) return;
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
activeIndex = (activeIndex + 1) % matches.length;
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
|
|
||||||
} else if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
const match = matches[Math.min(activeIndex, matches.length - 1)];
|
|
||||||
if (match) choose(match.id);
|
|
||||||
} else if (event.key === "Escape") {
|
|
||||||
listOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="search">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
onfocus={() => (listOpen = true)}
|
|
||||||
oninput={() => {
|
|
||||||
listOpen = true;
|
|
||||||
activeIndex = 0;
|
|
||||||
}}
|
|
||||||
onkeydown={onKeydown}
|
|
||||||
placeholder={t("search.placeholder")}
|
|
||||||
aria-label={t("search.aria")}
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={listOpen}
|
|
||||||
aria-controls="tool-search-list"
|
|
||||||
/>
|
|
||||||
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
|
||||||
<p class="none text-muted">{t("search.nothingFound")}</p>
|
|
||||||
{:else if listOpen && matches.length > 0}
|
|
||||||
<div id="tool-search-list" class="cards" role="listbox">
|
|
||||||
{#each matches as match, index (match.id)}
|
|
||||||
<ToolCard
|
|
||||||
toolId={match.id}
|
|
||||||
title={match.title}
|
|
||||||
description={match.description}
|
|
||||||
selected={index === activeIndex}
|
|
||||||
onActivate={() => choose(match.id)}
|
|
||||||
onHover={() => (activeIndex = index)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.search {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 36rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
background: var(--surface);
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
border-color: var(--link);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: calc(100% + var(--space-1));
|
|
||||||
z-index: 40;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-1);
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--space-1);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
max-height: 26rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 40rem) {
|
|
||||||
.cards {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.none {
|
|
||||||
margin: var(--space-1) 0 0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--text-s);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ImageInfo } from "$lib/core/analyze";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/v1/registry";
|
|
||||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import DownloadButton from "../DownloadButton.svelte";
|
|
||||||
import Preview from "../Preview.svelte";
|
|
||||||
import OverlayCard from "../tool/OverlayCard.svelte";
|
|
||||||
import ParamsCard from "../tool/ParamsCard.svelte";
|
|
||||||
import ResultCard from "../tool/ResultCard.svelte";
|
|
||||||
import SourceCard from "../tool/SourceCard.svelte";
|
|
||||||
import TextInputCard from "../tool/TextInputCard.svelte";
|
|
||||||
import Button from "../ui/Button.svelte";
|
|
||||||
import EmptyState from "../ui/EmptyState.svelte";
|
|
||||||
import type { StageStatus } from "./stage-props";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode: "base" | "chain";
|
|
||||||
header?: Snippet;
|
|
||||||
tool: ToolEntry;
|
|
||||||
source?: PixelImage | null;
|
|
||||||
displayImage?: PixelImage | null;
|
|
||||||
info?: ImageInfo | null;
|
|
||||||
status?: StageStatus;
|
|
||||||
isInfo?: boolean;
|
|
||||||
isSourceless?: boolean;
|
|
||||||
isTextSource?: boolean;
|
|
||||||
textResult?: string | null;
|
|
||||||
sanitized?: Record<string, any>;
|
|
||||||
canChainBase?: boolean;
|
|
||||||
hasChain?: boolean;
|
|
||||||
hasMask?: boolean;
|
|
||||||
pipetteTargetId?: string | null;
|
|
||||||
values: Record<string, any>;
|
|
||||||
showMask?: boolean;
|
|
||||||
handleFile?: (file: File) => Promise<void> | void;
|
|
||||||
onTextSubmit?: (text: string) => Promise<void> | void;
|
|
||||||
onSourceError?: (e: unknown) => void;
|
|
||||||
reset?: () => void;
|
|
||||||
toggleChain?: () => void;
|
|
||||||
handlePipetteToggle?: (id: string) => void;
|
|
||||||
handlePickColor?: (hex: string) => void;
|
|
||||||
showError?: (e: unknown) => void;
|
|
||||||
overlayImage?: PixelImage | null;
|
|
||||||
onOverlayFile?: (file: File) => void;
|
|
||||||
onOverlayError?: (e: unknown) => void;
|
|
||||||
onOverlayClear?: () => void;
|
|
||||||
index?: number;
|
|
||||||
input?: PixelImage | null;
|
|
||||||
result?: PixelImage | null;
|
|
||||||
busy?: boolean;
|
|
||||||
isLast?: boolean;
|
|
||||||
onRemove?: () => void;
|
|
||||||
onError?: (e: unknown) => void;
|
|
||||||
onAddStep?: () => void;
|
|
||||||
onRemoveChain?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
mode,
|
|
||||||
header,
|
|
||||||
tool,
|
|
||||||
source = null,
|
|
||||||
displayImage = null,
|
|
||||||
info = null,
|
|
||||||
status = "idle",
|
|
||||||
isInfo = false,
|
|
||||||
isSourceless = false,
|
|
||||||
isTextSource = false,
|
|
||||||
textResult = null,
|
|
||||||
sanitized = {},
|
|
||||||
canChainBase = false,
|
|
||||||
hasChain = false,
|
|
||||||
hasMask = false,
|
|
||||||
pipetteTargetId = null,
|
|
||||||
values = $bindable({}),
|
|
||||||
showMask = $bindable(false),
|
|
||||||
handleFile,
|
|
||||||
onTextSubmit,
|
|
||||||
onSourceError,
|
|
||||||
reset,
|
|
||||||
toggleChain,
|
|
||||||
handlePipetteToggle,
|
|
||||||
handlePickColor,
|
|
||||||
showError,
|
|
||||||
overlayImage = null,
|
|
||||||
onOverlayFile,
|
|
||||||
onOverlayError,
|
|
||||||
onOverlayClear,
|
|
||||||
index = 0,
|
|
||||||
input = null,
|
|
||||||
result = null,
|
|
||||||
busy = false,
|
|
||||||
isLast = false,
|
|
||||||
onRemove,
|
|
||||||
onError,
|
|
||||||
onAddStep,
|
|
||||||
onRemoveChain,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const isChain = $derived(mode === "chain");
|
|
||||||
const format = $derived(outputOf(tool));
|
|
||||||
const safeParams = $derived(sanitizeParams(tool, values ?? {}));
|
|
||||||
const StepIcon = $derived(TOOL_ICONS[tool.id]);
|
|
||||||
const showParams = $derived(
|
|
||||||
mode === "chain"
|
|
||||||
? tool.params.length > 0
|
|
||||||
: (source !== null || isSourceless) &&
|
|
||||||
!isInfo &&
|
|
||||||
(tool.params.length > 0 || hasMask),
|
|
||||||
);
|
|
||||||
|
|
||||||
const leftLegend = $derived(
|
|
||||||
isChain ? t("chain.inputLegend") : t("toolPage.legendSource"),
|
|
||||||
);
|
|
||||||
const midLegend = $derived(
|
|
||||||
isChain ? t("chain.paramsLegend") : t("toolPage.legendParams"),
|
|
||||||
);
|
|
||||||
const rightLegend = $derived(
|
|
||||||
isChain
|
|
||||||
? t("chain.resultLegend")
|
|
||||||
: isInfo
|
|
||||||
? t("toolPage.legendSummary")
|
|
||||||
: t("toolPage.legendResult"),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="panel stage-grid"
|
|
||||||
class:no-params={!showParams}
|
|
||||||
class:single={!isChain && !!isSourceless}
|
|
||||||
>
|
|
||||||
{#if header}
|
|
||||||
{@render header()}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if isChain}
|
|
||||||
<section class="pane">
|
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true"
|
|
||||||
>{leftLegend}</span
|
|
||||||
>
|
|
||||||
<div class="pane-body">
|
|
||||||
<Preview image={input} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{:else if !isSourceless}
|
|
||||||
<section class="pane">
|
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true"
|
|
||||||
>{leftLegend}</span
|
|
||||||
>
|
|
||||||
<div class="pane-body">
|
|
||||||
{#if isTextSource && !source}
|
|
||||||
<TextInputCard onSubmit={(text) => onTextSubmit?.(text)} />
|
|
||||||
{:else}
|
|
||||||
<SourceCard
|
|
||||||
{source}
|
|
||||||
onFile={(f) => handleFile?.(f)}
|
|
||||||
onError={(e) => onSourceError?.(e)}
|
|
||||||
onReset={() => reset?.()}
|
|
||||||
pipetteActive={!!pipetteTargetId}
|
|
||||||
onPickColor={(hex) => handlePickColor?.(hex)}
|
|
||||||
/>
|
|
||||||
{#if tool.needsOverlaySource && source}
|
|
||||||
<OverlayCard
|
|
||||||
overlay={overlayImage}
|
|
||||||
onFile={(f) => onOverlayFile?.(f)}
|
|
||||||
onError={(e) => onOverlayError?.(e)}
|
|
||||||
onClear={() => onOverlayClear?.()}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showParams}
|
|
||||||
<section class="pane pane-params">
|
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true">{midLegend}</span
|
|
||||||
>
|
|
||||||
<div class="pane-body">
|
|
||||||
<ParamsCard
|
|
||||||
{tool}
|
|
||||||
params={tool.params}
|
|
||||||
bind:values
|
|
||||||
{pipetteTargetId}
|
|
||||||
onPipetteToggle={(id) => handlePipetteToggle?.(id)}
|
|
||||||
{hasMask}
|
|
||||||
bind:showMask
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="pane">
|
|
||||||
<span class="edge-legend pane-legend" aria-hidden="true">{rightLegend}</span
|
|
||||||
>
|
|
||||||
<div class="pane-body">
|
|
||||||
{#if isChain}
|
|
||||||
{#if busy && !result}
|
|
||||||
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
|
|
||||||
{:else}
|
|
||||||
<Preview image={result} />
|
|
||||||
{#if busy}
|
|
||||||
<span class="recalc" aria-live="polite"
|
|
||||||
>{t("resultCard.recalc")}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<div class="actions-row">
|
|
||||||
<DownloadButton
|
|
||||||
image={result}
|
|
||||||
{format}
|
|
||||||
baseName="{index + 2}-{tool.id}"
|
|
||||||
params={safeParams}
|
|
||||||
onError={(e) => showError?.(e)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
fullWidth
|
|
||||||
onclick={isLast ? onAddStep : onRemoveChain}
|
|
||||||
>
|
|
||||||
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<ResultCard
|
|
||||||
{tool}
|
|
||||||
sourceLoaded={isSourceless ? true : !!source}
|
|
||||||
{status}
|
|
||||||
{result}
|
|
||||||
{displayImage}
|
|
||||||
{info}
|
|
||||||
{isInfo}
|
|
||||||
params={sanitized}
|
|
||||||
{textResult}
|
|
||||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
|
||||||
{hasChain}
|
|
||||||
onDownloadError={(e) => showError?.(e)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.stage-grid {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 14rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane + .pane {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Параметры в узкой колонке: вертикальные поля с переносами.
|
|
||||||
:global под локальным якорем даёт специфичность выше scoped-правил полей. */
|
|
||||||
.pane-params :global(.root) {
|
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(.params-grid) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(.field) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(.field > label) {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(.control-slot),
|
|
||||||
.pane-params :global(.row) {
|
|
||||||
align-items: stretch;
|
|
||||||
max-width: none;
|
|
||||||
width: 100%;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(.hint) {
|
|
||||||
align-self: auto;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(input[type="range"]) {
|
|
||||||
order: -1;
|
|
||||||
flex: 1 1 100%;
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(.row > output) {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-params :global(input.control),
|
|
||||||
.pane-params :global(select.control) {
|
|
||||||
max-width: none;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recalc {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--space-2);
|
|
||||||
right: var(--space-2);
|
|
||||||
padding: 2px var(--space-2);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
|
||||||
color: var(--link);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 75rem) {
|
|
||||||
.stage-grid:not(.no-params):not(.single) {
|
|
||||||
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 18rem) minmax(
|
|
||||||
0,
|
|
||||||
1fr
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage-grid.no-params:not(.single),
|
|
||||||
.stage-grid.single.no-params {
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage-grid.single:not(.no-params) {
|
|
||||||
grid-template-columns: clamp(13rem, 17vw, 18rem) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane + .pane {
|
|
||||||
border-top: none;
|
|
||||||
padding-top: 0;
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
padding-left: var(--space-4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import ParamsCard from "../tool/ParamsCard.svelte";
|
|
||||||
import ResultCard from "../tool/ResultCard.svelte";
|
|
||||||
import SourceCard from "../tool/SourceCard.svelte";
|
|
||||||
import TextInputCard from "../tool/TextInputCard.svelte";
|
|
||||||
import type { StageProps } from "./stage-props";
|
|
||||||
|
|
||||||
let {
|
|
||||||
tool,
|
|
||||||
source,
|
|
||||||
result,
|
|
||||||
displayImage,
|
|
||||||
info,
|
|
||||||
status,
|
|
||||||
isInfo,
|
|
||||||
isSourceless,
|
|
||||||
isTextSource,
|
|
||||||
textResult,
|
|
||||||
sanitized,
|
|
||||||
canChainBase,
|
|
||||||
hasChain,
|
|
||||||
hasMask,
|
|
||||||
showMask = $bindable(false),
|
|
||||||
values = $bindable(),
|
|
||||||
pipetteTargetId,
|
|
||||||
handleFile,
|
|
||||||
onTextSubmit,
|
|
||||||
onSourceError,
|
|
||||||
reset,
|
|
||||||
toggleChain,
|
|
||||||
handlePipetteToggle,
|
|
||||||
handlePickColor,
|
|
||||||
showError,
|
|
||||||
errorMessage,
|
|
||||||
}: StageProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="panel tool-block">
|
|
||||||
<div class="tool-stage" class:single={isSourceless}>
|
|
||||||
{#if !isSourceless}
|
|
||||||
<span class="edge-legend source-legend" aria-hidden="true"
|
|
||||||
>{t("toolPage.legendSource")}</span
|
|
||||||
>
|
|
||||||
<div class="cell">
|
|
||||||
{#if isTextSource && !source}
|
|
||||||
<TextInputCard onSubmit={onTextSubmit} />
|
|
||||||
{:else}
|
|
||||||
<SourceCard
|
|
||||||
{source}
|
|
||||||
onFile={handleFile}
|
|
||||||
onError={onSourceError}
|
|
||||||
onReset={reset}
|
|
||||||
pipetteActive={!!pipetteTargetId}
|
|
||||||
onPickColor={handlePickColor}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span class="edge-legend result-legend" aria-hidden="true">
|
|
||||||
{isInfo ? t("toolPage.legendSummary") : t("toolPage.legendResult")}
|
|
||||||
</span>
|
|
||||||
<div class="cell">
|
|
||||||
<ResultCard
|
|
||||||
{tool}
|
|
||||||
sourceLoaded={isSourceless ? true : !!source}
|
|
||||||
{status}
|
|
||||||
{result}
|
|
||||||
{displayImage}
|
|
||||||
{info}
|
|
||||||
{isInfo}
|
|
||||||
params={sanitized}
|
|
||||||
{textResult}
|
|
||||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
|
||||||
{hasChain}
|
|
||||||
onDownloadError={showError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
|
|
||||||
<div class="params-sep">
|
|
||||||
<span class="edge-legend" aria-hidden="true"
|
|
||||||
>{t("toolPage.legendParams")}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<ParamsCard
|
|
||||||
{tool}
|
|
||||||
params={tool.params}
|
|
||||||
bind:values
|
|
||||||
{pipetteTargetId}
|
|
||||||
onPipetteToggle={handlePipetteToggle}
|
|
||||||
{hasMask}
|
|
||||||
bind:showMask
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tool-stage {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-legend {
|
|
||||||
left: 25%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-legend {
|
|
||||||
left: 75%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
|
||||||
.source-legend,
|
|
||||||
.result-legend {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { ImageInfo } from "$lib/core/analyze";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import type { ToolEntry } from "$lib/v1/registry";
|
|
||||||
|
|
||||||
export type StageStatus = "idle" | "loaded" | "processing" | "error";
|
|
||||||
|
|
||||||
export interface StageProps {
|
|
||||||
tool: ToolEntry;
|
|
||||||
source: PixelImage | null;
|
|
||||||
result: PixelImage | null;
|
|
||||||
displayImage: PixelImage | null;
|
|
||||||
info: ImageInfo | null;
|
|
||||||
status: StageStatus;
|
|
||||||
isInfo: boolean;
|
|
||||||
isSourceless: boolean;
|
|
||||||
isTextSource: boolean;
|
|
||||||
textResult: string | null;
|
|
||||||
sanitized: Record<string, any>;
|
|
||||||
canChainBase: boolean;
|
|
||||||
hasChain: boolean;
|
|
||||||
hasMask: boolean;
|
|
||||||
showMask: boolean;
|
|
||||||
values: Record<string, any>;
|
|
||||||
pipetteTargetId: string | null;
|
|
||||||
handleFile: (file: File) => Promise<void> | void;
|
|
||||||
onTextSubmit: (text: string) => Promise<void> | void;
|
|
||||||
onSourceError: (e: unknown) => void;
|
|
||||||
reset: () => void;
|
|
||||||
toggleChain: () => void;
|
|
||||||
handlePipetteToggle: (id: string) => void;
|
|
||||||
handlePickColor: (hex: string) => void;
|
|
||||||
showError: (e: unknown) => void;
|
|
||||||
errorMessage: (e: unknown) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChainStageProps {
|
|
||||||
tool: ToolEntry;
|
|
||||||
index: number;
|
|
||||||
input: PixelImage | null;
|
|
||||||
result: PixelImage | null;
|
|
||||||
busy: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
values: Record<string, any>;
|
|
||||||
onRemove: () => void;
|
|
||||||
onError: (e: unknown) => void;
|
|
||||||
onAddStep: () => void;
|
|
||||||
onRemoveChain: () => void;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import DropZone from "../DropZone.svelte";
|
|
||||||
import Preview from "../Preview.svelte";
|
|
||||||
import Button from "../ui/Button.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
overlay: PixelImage | null;
|
|
||||||
onFile: (file: File) => void;
|
|
||||||
onError: (e: unknown) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { overlay, onFile, onError, onClear }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="overlay-card">
|
|
||||||
<span class="edge-legend overlay-legend" aria-hidden="true"
|
|
||||||
>{t("ui.overlayTitle")}</span
|
|
||||||
>
|
|
||||||
{#if !overlay}
|
|
||||||
<DropZone {onFile} {onError} label={t("ui.overlayDrop")} />
|
|
||||||
{:else}
|
|
||||||
<div class="preview-wrap">
|
|
||||||
<Preview image={overlay} />
|
|
||||||
<Button variant="secondary" onclick={onClear}
|
|
||||||
>{t("ui.overlayRemove")}</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.overlay-card {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
padding-top: var(--space-3);
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-legend {
|
|
||||||
top: -0.65em;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-card :global(.dropzone) {
|
|
||||||
min-height: 7rem;
|
|
||||||
font-size: var(--text-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-wrap :global(.media) {
|
|
||||||
max-height: 12rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
|
|
||||||
import ParamForm from "../ParamForm.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tool: ToolEntry;
|
|
||||||
params: ParamDef[];
|
|
||||||
values: Record<string, any>;
|
|
||||||
pipetteTargetId?: string | null;
|
|
||||||
onPipetteToggle?: (id: string) => void;
|
|
||||||
hasMask?: boolean;
|
|
||||||
showMask?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
tool,
|
|
||||||
params,
|
|
||||||
values = $bindable(),
|
|
||||||
pipetteTargetId = null,
|
|
||||||
onPipetteToggle,
|
|
||||||
hasMask = false,
|
|
||||||
showMask = $bindable(false),
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
{#if params.length > 0 || hasMask}
|
|
||||||
<ParamForm
|
|
||||||
{tool}
|
|
||||||
{params}
|
|
||||||
bind:values
|
|
||||||
{pipetteTargetId}
|
|
||||||
{onPipetteToggle}
|
|
||||||
{hasMask}
|
|
||||||
bind:showMask
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<p class="hint text-caption text-muted">{t("paramsCard.noParams")}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root {
|
|
||||||
padding: 0 var(--space-4) var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
image: PixelImage;
|
|
||||||
px: number;
|
|
||||||
py: number;
|
|
||||||
hex: string;
|
|
||||||
clientX: number;
|
|
||||||
clientY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { image, px, py, hex, clientX, clientY }: Props = $props();
|
|
||||||
|
|
||||||
const SIZE = 110;
|
|
||||||
const ZOOM = 8;
|
|
||||||
const BLOCK = 15;
|
|
||||||
const HALF = Math.floor(BLOCK / 2);
|
|
||||||
const CENTER = SIZE / 2;
|
|
||||||
|
|
||||||
let loupeCanvas = $state<HTMLCanvasElement | undefined>();
|
|
||||||
|
|
||||||
let srcCanvas: HTMLCanvasElement | null = null;
|
|
||||||
let srcKey: PixelImage | null = null;
|
|
||||||
|
|
||||||
function ensureSource(): HTMLCanvasElement | null {
|
|
||||||
if (!srcCanvas || srcKey !== image) {
|
|
||||||
srcCanvas = document.createElement("canvas");
|
|
||||||
srcCanvas.width = image.width;
|
|
||||||
srcCanvas.height = image.height;
|
|
||||||
srcCanvas
|
|
||||||
.getContext("2d")
|
|
||||||
?.putImageData(
|
|
||||||
new ImageData(image.data, image.width, image.height),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
srcKey = image;
|
|
||||||
}
|
|
||||||
return srcCanvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(v: number, min: number, max: number): number {
|
|
||||||
return Math.min(max, Math.max(min, v));
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockX = $derived(
|
|
||||||
clamp(px - HALF, 0, Math.max(0, image.width - BLOCK)),
|
|
||||||
);
|
|
||||||
const blockY = $derived(
|
|
||||||
clamp(py - HALF, 0, Math.max(0, image.height - BLOCK)),
|
|
||||||
);
|
|
||||||
const flipBelow = $derived(clientY < SIZE + 24);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!loupeCanvas) return;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
loupeCanvas.width = SIZE * dpr;
|
|
||||||
loupeCanvas.height = SIZE * dpr;
|
|
||||||
const ctx = loupeCanvas.getContext("2d");
|
|
||||||
const src = ensureSource();
|
|
||||||
if (!ctx || !src) return;
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
ctx.imageSmoothingEnabled = false;
|
|
||||||
ctx.fillStyle = "#111318";
|
|
||||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
|
||||||
ctx.drawImage(
|
|
||||||
src,
|
|
||||||
blockX,
|
|
||||||
blockY,
|
|
||||||
BLOCK,
|
|
||||||
BLOCK,
|
|
||||||
CENTER + (blockX - px) * ZOOM,
|
|
||||||
CENTER + (blockY - py) * ZOOM,
|
|
||||||
BLOCK * ZOOM,
|
|
||||||
BLOCK * ZOOM,
|
|
||||||
);
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeRect(
|
|
||||||
CENTER - ZOOM / 2 + 0.5,
|
|
||||||
CENTER - ZOOM / 2 + 0.5,
|
|
||||||
ZOOM,
|
|
||||||
ZOOM,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="loupe"
|
|
||||||
class:flip={flipBelow}
|
|
||||||
style="left:{clientX}px;top:{clientY}px"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<canvas bind:this={loupeCanvas} style="width:{SIZE}px;height:{SIZE}px"
|
|
||||||
></canvas>
|
|
||||||
<p class="hex">{hex}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.loupe {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 50;
|
|
||||||
transform: translate(-50%, calc(-100% - 16px));
|
|
||||||
padding: 4px 4px 2px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loupe.flip {
|
|
||||||
transform: translate(-50%, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hex {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ImageInfo } from "$lib/core/analyze";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { outputOf, type ToolEntry } from "$lib/v1/registry";
|
|
||||||
import DownloadButton from "../DownloadButton.svelte";
|
|
||||||
import InfoPanel from "../InfoPanel.svelte";
|
|
||||||
import Preview from "../Preview.svelte";
|
|
||||||
import Button from "../ui/Button.svelte";
|
|
||||||
import EmptyState from "../ui/EmptyState.svelte";
|
|
||||||
import TextResult from "./TextResult.svelte";
|
|
||||||
|
|
||||||
type Status = "idle" | "loaded" | "processing" | "error";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tool: ToolEntry;
|
|
||||||
sourceLoaded: boolean;
|
|
||||||
status: Status;
|
|
||||||
result: PixelImage | null;
|
|
||||||
displayImage: PixelImage | null;
|
|
||||||
info: ImageInfo | null;
|
|
||||||
isInfo: boolean;
|
|
||||||
params: Record<string, unknown>;
|
|
||||||
textResult: string | null;
|
|
||||||
onChainToggle?: () => void;
|
|
||||||
hasChain?: boolean;
|
|
||||||
onDownloadError: (e: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
tool,
|
|
||||||
sourceLoaded,
|
|
||||||
status,
|
|
||||||
result,
|
|
||||||
displayImage,
|
|
||||||
info,
|
|
||||||
isInfo,
|
|
||||||
params,
|
|
||||||
textResult,
|
|
||||||
onChainToggle,
|
|
||||||
hasChain = false,
|
|
||||||
onDownloadError,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
{#if !sourceLoaded}
|
|
||||||
<div class="media">
|
|
||||||
<EmptyState
|
|
||||||
title={t("resultCard.emptyTitle")}
|
|
||||||
hint={t("resultCard.emptyHint")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if !isInfo && status === "processing" && !result}
|
|
||||||
<div class="media">
|
|
||||||
<EmptyState
|
|
||||||
title={t("resultCard.processingTitle")}
|
|
||||||
hint={t("resultCard.processingHint")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if isInfo}
|
|
||||||
<div class="media">
|
|
||||||
{#if info}
|
|
||||||
<InfoPanel {info} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if tool.resultType === "text"}
|
|
||||||
<div class="media">
|
|
||||||
{#if textResult !== null}
|
|
||||||
<TextResult text={textResult} filename={tool.id} toolId={tool.id} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="media">
|
|
||||||
<Preview image={displayImage} />
|
|
||||||
{#if status === "processing"}
|
|
||||||
<span class="recalc" aria-live="polite">{t("resultCard.recalc")}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="actions-row">
|
|
||||||
<DownloadButton
|
|
||||||
image={result}
|
|
||||||
format={outputOf(tool)}
|
|
||||||
baseName={tool.id}
|
|
||||||
{params}
|
|
||||||
onError={onDownloadError}
|
|
||||||
/>
|
|
||||||
{#if onChainToggle}
|
|
||||||
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
|
||||||
{hasChain ? t("resultCard.breakChain") : t("resultCard.nextTool")}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 16rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recalc {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--space-2);
|
|
||||||
right: var(--space-2);
|
|
||||||
padding: 2px var(--space-2);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
|
||||||
color: var(--link);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import DropOverlay from "../DropOverlay.svelte";
|
|
||||||
import DropZone from "../DropZone.svelte";
|
|
||||||
import Preview from "../Preview.svelte";
|
|
||||||
import Button from "../ui/Button.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
source: PixelImage | null;
|
|
||||||
onFile: (file: File) => void;
|
|
||||||
onError: (e: unknown) => void;
|
|
||||||
onReset: () => void;
|
|
||||||
pipetteActive?: boolean;
|
|
||||||
onPickColor?: (hex: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
source,
|
|
||||||
onFile,
|
|
||||||
onError,
|
|
||||||
onReset,
|
|
||||||
pipetteActive = false,
|
|
||||||
onPickColor,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
{#if !source}
|
|
||||||
<DropZone {onFile} {onError} />
|
|
||||||
{:else}
|
|
||||||
<DropOverlay {onFile} {onError}>
|
|
||||||
<div class="media">
|
|
||||||
<Preview image={source} {pipetteActive} {onPickColor} />
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" onclick={onReset}
|
|
||||||
>{t("sourceCard.replaceImage")}</Button
|
|
||||||
>
|
|
||||||
</DropOverlay>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 16rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Button from "../ui/Button.svelte";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSubmit: (text: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onSubmit }: Props = $props();
|
|
||||||
|
|
||||||
let text = $state("");
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
if (text.trim().length === 0) return;
|
|
||||||
onSubmit(text);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="heading-section">{t("textInput.heading")}</h2>
|
|
||||||
<textarea
|
|
||||||
class="input"
|
|
||||||
rows="8"
|
|
||||||
bind:value={text}
|
|
||||||
placeholder={t("textInput.placeholder")}
|
|
||||||
aria-label={t("textInput.aria")}></textarea>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onclick={submit}
|
|
||||||
disabled={text.trim().length === 0}
|
|
||||||
>
|
|
||||||
{t("textInput.decode")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
resize: vertical;
|
|
||||||
padding: var(--space-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--surface);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-s);
|
|
||||||
min-height: 12rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { downloadBlob } from "$lib/core/io";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { getMergedDict } from "$lib/v1/i18n/locale.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
text: string;
|
|
||||||
filename: string;
|
|
||||||
toolId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { text, filename, toolId }: Props = $props();
|
|
||||||
|
|
||||||
const shown = $derived(
|
|
||||||
toolId ? (getMergedDict().tools[toolId]?.results?.[text] ?? text) : text,
|
|
||||||
);
|
|
||||||
|
|
||||||
let copied = $state(false);
|
|
||||||
|
|
||||||
async function copy() {
|
|
||||||
await navigator.clipboard.writeText(shown);
|
|
||||||
copied = true;
|
|
||||||
setTimeout(() => (copied = false), 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function download() {
|
|
||||||
downloadBlob(new Blob([shown], { type: "text/plain" }), `${filename}.txt`);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="panel text-result">
|
|
||||||
<textarea
|
|
||||||
class="output"
|
|
||||||
rows="10"
|
|
||||||
readonly
|
|
||||||
value={shown}
|
|
||||||
aria-label={t("textResult.outputAria")}></textarea>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="secondary" onclick={copy}>
|
|
||||||
{copied ? t("textResult.copied") : t("textResult.copy")}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="primary" onclick={download}
|
|
||||||
>{t("textResult.downloadTxt")}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.text-result {
|
|
||||||
padding: var(--space-3);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.output {
|
|
||||||
width: 100%;
|
|
||||||
resize: vertical;
|
|
||||||
padding: var(--space-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--bg);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions button {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--text-m);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions .primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions .primary:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions .secondary {
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions .secondary:hover {
|
|
||||||
border-color: var(--link);
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
variant?: "primary" | "secondary";
|
|
||||||
type?: "button" | "submit";
|
|
||||||
disabled?: boolean;
|
|
||||||
busy?: boolean;
|
|
||||||
busyText?: string;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
onclick?: () => void;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
variant = "primary",
|
|
||||||
type = "button",
|
|
||||||
disabled = false,
|
|
||||||
busy = false,
|
|
||||||
busyText = "",
|
|
||||||
fullWidth = false,
|
|
||||||
onclick,
|
|
||||||
children,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
{type}
|
|
||||||
class={fullWidth ? `${variant} fullwidth` : variant}
|
|
||||||
aria-busy={busy}
|
|
||||||
disabled={disabled || busy}
|
|
||||||
{onclick}
|
|
||||||
>
|
|
||||||
{#if busy && busyText}
|
|
||||||
{busyText}
|
|
||||||
{:else}
|
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
button {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--text-m);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background var(--transition-fast),
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary:hover:not(:disabled) {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
button.secondary {
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
button.secondary:hover:not(:disabled) {
|
|
||||||
border-color: var(--link);
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullwidth {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
checked?: boolean;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { id, label, checked = $bindable(false), hint }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="checkbox-row">
|
|
||||||
<label class="checkbox" for={id}>
|
|
||||||
<input {id} type="checkbox" bind:checked />
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{#if hint}
|
|
||||||
<p class="hint text-caption text-muted">{hint}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.checkbox-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: calc(var(--space-1) + 2px) 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--text-caption, var(--text-s));
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
label:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Field from "./Field.svelte";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value?: string;
|
|
||||||
hint?: string;
|
|
||||||
pipetteActive?: boolean;
|
|
||||||
onPipetteToggle?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
value = $bindable("#000000"),
|
|
||||||
hint,
|
|
||||||
pipetteActive = false,
|
|
||||||
onPipetteToggle,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
|
||||||
<div class="row">
|
|
||||||
<input {id} class="control swatch" type="color" bind:value />
|
|
||||||
{#if onPipetteToggle}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pipette"
|
|
||||||
aria-label={t("ui.pipette")}
|
|
||||||
aria-pressed={pipetteActive}
|
|
||||||
title={t("ui.pipette")}
|
|
||||||
onclick={onPipetteToggle}
|
|
||||||
>
|
|
||||||
◎
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.swatch {
|
|
||||||
height: var(--control-height);
|
|
||||||
width: 3.5rem;
|
|
||||||
padding: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipette {
|
|
||||||
flex: none;
|
|
||||||
width: var(--control-height);
|
|
||||||
height: var(--control-height);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
color var(--transition-fast),
|
|
||||||
background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipette:hover {
|
|
||||||
border-color: var(--link);
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pipette[aria-pressed="true"] {
|
|
||||||
border-color: var(--link);
|
|
||||||
color: var(--link);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
hint?: string;
|
|
||||||
children?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { title, hint, children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="empty panel">
|
|
||||||
<p class="title">{title}</p>
|
|
||||||
{#if hint}
|
|
||||||
<p class="text-caption text-muted">{hint}</p>
|
|
||||||
{/if}
|
|
||||||
{#if children}
|
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
min-height: 8rem;
|
|
||||||
padding: var(--space-4);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { id, label, hint, children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="text-caption text-muted" for={id}>{label}</label>
|
|
||||||
<div class="control-slot">
|
|
||||||
{@render children()}
|
|
||||||
{#if hint}
|
|
||||||
<p class="hint text-caption text-muted">{hint}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: calc(var(--space-1) + 2px) 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
flex: none;
|
|
||||||
max-width: 45%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-slot {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
align-self: flex-end;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Field from "./Field.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value?: string;
|
|
||||||
options: { value: string; label: string }[];
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { id, label, value = $bindable(""), options, hint }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
|
||||||
<select {id} class="control" bind:value>
|
|
||||||
{#each options as option (option.value)}
|
|
||||||
<option value={option.value}>{option.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Field from "./Field.svelte";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value?: number;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
default?: number;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
value = $bindable(0),
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
default: defaultValue,
|
|
||||||
hint,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
function decrement() {
|
|
||||||
if (min !== undefined && value <= min) return;
|
|
||||||
value = Math.max(min ?? -Infinity, value - (step ?? 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function increment() {
|
|
||||||
if (max !== undefined && value >= max) return;
|
|
||||||
value = Math.min(max ?? Infinity, value + (step ?? 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
if (defaultValue !== undefined) value = defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetDisabled = $derived(
|
|
||||||
defaultValue === undefined || value === defaultValue,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
|
||||||
<div class="row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="step"
|
|
||||||
aria-label={t("ui.decrease")}
|
|
||||||
onclick={decrement}>−</button
|
|
||||||
>
|
|
||||||
<input {id} type="range" {min} {max} {step} bind:value />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="step"
|
|
||||||
aria-label={t("ui.increase")}
|
|
||||||
onclick={increment}>+</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="step"
|
|
||||||
aria-label={t("ui.reset")}
|
|
||||||
disabled={resetDisabled}
|
|
||||||
onclick={reset}
|
|
||||||
>
|
|
||||||
↺
|
|
||||||
</button>
|
|
||||||
<output>{value}</output>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 22rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="range"] {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 3rem;
|
|
||||||
accent-color: var(--link);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
flex: none;
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step:hover:not(:disabled) {
|
|
||||||
border-color: var(--link);
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
output {
|
|
||||||
flex: none;
|
|
||||||
min-width: 3ch;
|
|
||||||
text-align: right;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-s);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Field from "./Field.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value?: string | number;
|
|
||||||
type?: "number" | "text";
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
hint?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
value = $bindable(""),
|
|
||||||
type = "text",
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
hint,
|
|
||||||
placeholder,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
|
||||||
<input
|
|
||||||
class="control"
|
|
||||||
{id}
|
|
||||||
{type}
|
|
||||||
{min}
|
|
||||||
{max}
|
|
||||||
{step}
|
|
||||||
{placeholder}
|
|
||||||
bind:value
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
input.control::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { CategoryId } from "../categories";
|
|
||||||
|
|
||||||
export const LOCALES = ["ru", "en"] as const;
|
|
||||||
export type Locale = (typeof LOCALES)[number];
|
|
||||||
|
|
||||||
export const BASE_LOCALE: Locale = "en";
|
|
||||||
|
|
||||||
export const LOCALE_TAGS: Record<Locale, string> = {
|
|
||||||
ru: "ru-RU",
|
|
||||||
en: "en-US",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isLocale(value: unknown): value is Locale {
|
|
||||||
return (
|
|
||||||
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ToolStrings = {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
/** Подписи параметров по их id. */
|
|
||||||
params?: Record<string, string>;
|
|
||||||
/** Подписи опций select: paramId -> value -> label. */
|
|
||||||
options?: Record<string, Record<string, string>>;
|
|
||||||
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
|
|
||||||
results?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HeaderStrings = {
|
|
||||||
workspace: string;
|
|
||||||
catalog: string;
|
|
||||||
sectionsAria: string;
|
|
||||||
footerNote: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Dict = {
|
|
||||||
header: HeaderStrings;
|
|
||||||
categories: Record<CategoryId, string>;
|
|
||||||
home: Record<string, string>;
|
|
||||||
catalog: Record<string, string>;
|
|
||||||
toolPage: Record<string, string>;
|
|
||||||
chain: Record<string, string>;
|
|
||||||
sourceCard: Record<string, string>;
|
|
||||||
resultCard: Record<string, string>;
|
|
||||||
paramsCard: Record<string, string>;
|
|
||||||
textInput: Record<string, string>;
|
|
||||||
textResult: Record<string, string>;
|
|
||||||
download: Record<string, string>;
|
|
||||||
infoPanel: Record<string, string>;
|
|
||||||
dropZone: Record<string, string>;
|
|
||||||
search: Record<string, string>;
|
|
||||||
ui: Record<string, string>;
|
|
||||||
errors: Record<string, string>;
|
|
||||||
tools: Record<string, ToolStrings>;
|
|
||||||
};
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import type { Dict } from "./dict";
|
|
||||||
|
|
||||||
export const en: Dict = {
|
|
||||||
header: {
|
|
||||||
workspace: "Workspace",
|
|
||||||
catalog: "Catalog",
|
|
||||||
sectionsAria: "Sections",
|
|
||||||
footerNote:
|
|
||||||
"All operations run locally in your browser — your files are never uploaded anywhere.",
|
|
||||||
},
|
|
||||||
categories: {
|
|
||||||
convert: "Convert",
|
|
||||||
alpha: "Transparency",
|
|
||||||
color: "Color",
|
|
||||||
geometry: "Geometry",
|
|
||||||
filters: "Filters",
|
|
||||||
text: "Text",
|
|
||||||
analyze: "Analyze",
|
|
||||||
generate: "Generate",
|
|
||||||
},
|
|
||||||
home: {
|
|
||||||
defaultTitle: "easy-png-tools — PNG utilities right in your browser",
|
|
||||||
heroTitle: "What do you want to do with the image?",
|
|
||||||
heroLead: "Find a tool — everything runs locally in your browser.",
|
|
||||||
restoreLast: "↩ Restore last: {title}",
|
|
||||||
changeTool: "← Change tool",
|
|
||||||
},
|
|
||||||
catalog: {
|
|
||||||
pageTitle: "All tools — easy-png-tools",
|
|
||||||
metaDescription:
|
|
||||||
"Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.",
|
|
||||||
heading: "Tool catalog",
|
|
||||||
lead: "{count} utilities for working with PNG. Everything runs locally in your browser.",
|
|
||||||
},
|
|
||||||
toolPage: {
|
|
||||||
fallbackTitle: "Tool",
|
|
||||||
legendSource: "Source",
|
|
||||||
legendSummary: "Summary",
|
|
||||||
legendResult: "Result",
|
|
||||||
legendParams: "Parameters",
|
|
||||||
stepHeading: "Step {n}",
|
|
||||||
removeStepAria: "Remove step",
|
|
||||||
stepError: "Step {n} ({title}): {msg}",
|
|
||||||
},
|
|
||||||
chain: {
|
|
||||||
stepLabel: "Step {n}: {title}",
|
|
||||||
inputLegend: "Input",
|
|
||||||
resultLegend: "Result",
|
|
||||||
paramsLegend: "Parameters",
|
|
||||||
busyTitle: "Processing…",
|
|
||||||
busyHint: "Running chain step",
|
|
||||||
removeStepAria: "Remove step",
|
|
||||||
},
|
|
||||||
sourceCard: {
|
|
||||||
replaceImage: "Replace image",
|
|
||||||
},
|
|
||||||
resultCard: {
|
|
||||||
emptyTitle: "The result will appear here",
|
|
||||||
emptyHint: "First upload a source image on the left",
|
|
||||||
processingTitle: "Processing…",
|
|
||||||
processingHint: "The image is being processed, this will take a moment",
|
|
||||||
recalc: "Recalculating…",
|
|
||||||
nextTool: "⛓ Next tool",
|
|
||||||
breakChain: "✂ Break the chain",
|
|
||||||
},
|
|
||||||
paramsCard: {
|
|
||||||
noParams: "This tool has no parameters — the result is ready as is.",
|
|
||||||
},
|
|
||||||
textInput: {
|
|
||||||
heading: "Text",
|
|
||||||
placeholder: "Paste data here",
|
|
||||||
aria: "Text data",
|
|
||||||
decode: "Decode",
|
|
||||||
},
|
|
||||||
textResult: {
|
|
||||||
outputAria: "Text result",
|
|
||||||
copied: "Copied",
|
|
||||||
copy: "Copy",
|
|
||||||
downloadTxt: "Download .txt",
|
|
||||||
},
|
|
||||||
download: {
|
|
||||||
busy: "Preparing file…",
|
|
||||||
file: "Download .{ext}",
|
|
||||||
},
|
|
||||||
infoPanel: {
|
|
||||||
dimensions: "Dimensions",
|
|
||||||
alpha: "Alpha channel",
|
|
||||||
alphaYes: "yes — there are semi-transparent pixels",
|
|
||||||
alphaNo: "no",
|
|
||||||
colorCount: "Unique colors (RGBA)",
|
|
||||||
},
|
|
||||||
dropZone: {
|
|
||||||
pickDefault: "Drop an image here or click to choose a file",
|
|
||||||
overlayDefault: "Release the file to replace the image",
|
|
||||||
},
|
|
||||||
search: {
|
|
||||||
placeholder: "Find a tool…",
|
|
||||||
aria: "Search tools",
|
|
||||||
nothingFound: "Nothing found — try another word.",
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
showMask: "Show mask",
|
|
||||||
decrease: "Decrease",
|
|
||||||
increase: "Increase",
|
|
||||||
reset: "Reset",
|
|
||||||
pipette: "Eyedropper",
|
|
||||||
themeLight: "Light theme",
|
|
||||||
themeDark: "Dark theme",
|
|
||||||
overlayTitle: "Watermark",
|
|
||||||
overlayDrop: "Drop a watermark PNG or click",
|
|
||||||
overlayRemove: "Remove watermark",
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
noImageRun: "This tool does not process images",
|
|
||||||
sourceRequired: "Upload an image first",
|
|
||||||
textRequired: "Enter text first",
|
|
||||||
workerFailed: "Worker execution failed",
|
|
||||||
workerUnavailable: "Worker is unavailable",
|
|
||||||
notFound: "Tool not found",
|
|
||||||
noWatermark: "Pick a watermark image first",
|
|
||||||
badTransform: "Degenerate transformation matrix",
|
|
||||||
skewAngle: "Skew angles cannot be 90° or -90°",
|
|
||||||
badHex: 'Invalid HEX color: "{value}"',
|
|
||||||
radiusInt: "Radius must be a non-negative integer",
|
|
||||||
kernelSize: "Kernel does not match the image dimensions",
|
|
||||||
sizeInt: "Width and height must be integers ≥ 1",
|
|
||||||
cropBounds: "Crop area does not intersect the image",
|
|
||||||
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
|
|
||||||
badBase64: "Expected a base64 string or a data-uri of an image",
|
|
||||||
qualityRange: "quality must be within 0..1",
|
|
||||||
svgSize: "Could not determine SVG dimensions",
|
|
||||||
svgLoad: "Failed to load SVG — check the markup",
|
|
||||||
encodeUnsupported: "The browser does not support encoding to {mime}",
|
|
||||||
unsupportedFile:
|
|
||||||
"Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.",
|
|
||||||
widthInt: "Image width must be an integer ≥ 1",
|
|
||||||
noHexPixels: "No hex pixel values found",
|
|
||||||
badPixelToken:
|
|
||||||
"Each pixel must be 8 hex characters RRGGBBAA, separated by spaces",
|
|
||||||
pixelCountMismatch:
|
|
||||||
"Pixel count ({count}) is not divisible by width {width} without a remainder",
|
|
||||||
toolNotFound: 'Tool "{id}" not found',
|
|
||||||
badJson: "The file is not valid JSON",
|
|
||||||
badPipelineShape: "The file structure does not look like a chain of steps",
|
|
||||||
pipelineVersion: "Unsupported chain version: {version}",
|
|
||||||
noSteps: "The file has no list of steps",
|
|
||||||
paramNumber: 'Parameter "{id}" must be a number',
|
|
||||||
paramString: 'Parameter "{id}" must be a string',
|
|
||||||
paramBool: 'Parameter "{id}" must be a checkbox value',
|
|
||||||
resizeSize: "Width and/or height must be positive",
|
|
||||||
cropSize: "Crop width and height must be positive",
|
|
||||||
sizePositive: "Dimensions must be positive and finite",
|
|
||||||
tooManyParts: "Too many parts ({count}). Maximum is 1000.",
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
"png-file-size": {
|
|
||||||
results: {
|
|
||||||
line: "PNG size: {kb} KB",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"verify-is-png": {
|
|
||||||
results: {
|
|
||||||
verifyYes: "Yes — this is a valid PNG signature.",
|
|
||||||
verifyNo: "No — the signature does not match a PNG file.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"png-is-grayscale": {
|
|
||||||
results: {
|
|
||||||
grayscaleYes: "Yes — all pixels are shades of gray.",
|
|
||||||
grayscaleNo: "No — colored pixels were found.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"png-is-transparent": {
|
|
||||||
results: {
|
|
||||||
transparentYes:
|
|
||||||
"Yes — there are transparent or semi-transparent pixels.",
|
|
||||||
transparentNo: "No — all pixels are fully opaque.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"png-orientation": {
|
|
||||||
results: {
|
|
||||||
orientationPortrait: "Portrait — height is greater than width.",
|
|
||||||
orientationLandscape: "Landscape — width is greater than height.",
|
|
||||||
orientationSquare: "Square — the sides are equal.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { getLocale, initLocale, setLocale } from "./locale.svelte";
|
|
||||||
import { interpolate, t } from "./t";
|
|
||||||
import { en } from "./en";
|
|
||||||
|
|
||||||
type Store = Record<string, string>;
|
|
||||||
|
|
||||||
function stubStorage(): { store: Store } {
|
|
||||||
const store: Store = {};
|
|
||||||
vi.stubGlobal("localStorage", {
|
|
||||||
getItem: (k: string) => (k in store ? store[k] : null),
|
|
||||||
setItem: (k: string, v: string) => {
|
|
||||||
store[k] = v;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
vi.stubGlobal("window", {});
|
|
||||||
return { store };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setLocale("ru");
|
|
||||||
delete (en.home as Record<string, string>).onlyEnKey;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("t", () => {
|
|
||||||
it("возвращает строку по точечному пути активной локали", () => {
|
|
||||||
expect(t("header.workspace")).toBe("Рабочая область");
|
|
||||||
setLocale("en");
|
|
||||||
expect(t("header.catalog")).toBe("Catalog");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("фолбэк на английскую базу, если в активной локали нет ключа", () => {
|
|
||||||
(en.home as Record<string, string>).onlyEnKey = "Only English string";
|
|
||||||
setLocale("ru");
|
|
||||||
expect(t("home.onlyEnKey")).toBe("Only English string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("неизвестный путь возвращает сам путь", () => {
|
|
||||||
expect(t("no.such.key")).toBe("no.such.key");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("interpolate", () => {
|
|
||||||
it("подставляет переменные в шаблон", () => {
|
|
||||||
expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe(
|
|
||||||
"Шаг 2 из 5",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("оставляет плейсхолдер без переменной как есть", () => {
|
|
||||||
expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("без переменных возвращает строку без изменений", () => {
|
|
||||||
expect(interpolate("Просто текст")).toBe("Просто текст");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("персист локали", () => {
|
|
||||||
it("initLocale читает сохранённый выбор", () => {
|
|
||||||
const { store } = stubStorage();
|
|
||||||
store["locale"] = "en";
|
|
||||||
initLocale();
|
|
||||||
expect(getLocale()).toBe("en");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("initLocale игнорирует мусор в хранилище", () => {
|
|
||||||
const { store } = stubStorage();
|
|
||||||
store["locale"] = "fr";
|
|
||||||
initLocale();
|
|
||||||
expect(getLocale()).toBe("ru");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setLocale сохраняет выбор в localStorage", () => {
|
|
||||||
const { store } = stubStorage();
|
|
||||||
initLocale();
|
|
||||||
setLocale("en");
|
|
||||||
expect(store["locale"]).toBe("en");
|
|
||||||
expect(getLocale()).toBe("en");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict";
|
|
||||||
import { ru } from "./ru";
|
|
||||||
import { en } from "./en";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "locale";
|
|
||||||
|
|
||||||
const DICTS: Record<Locale, Dict> = { ru, en };
|
|
||||||
|
|
||||||
let locale = $state<Locale>(BASE_LOCALE);
|
|
||||||
|
|
||||||
function storage(): Storage | null {
|
|
||||||
return typeof localStorage === "undefined" ? null : localStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLocale(): Locale {
|
|
||||||
return locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDict(): Dict {
|
|
||||||
return DICTS[locale];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
|
|
||||||
export function getMergedDict(): Dict {
|
|
||||||
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
|
|
||||||
const active = DICTS[locale];
|
|
||||||
const base = DICTS[BASE_LOCALE];
|
|
||||||
return new Proxy(base, {
|
|
||||||
get(_target, section: string) {
|
|
||||||
const a = (active as unknown as Record<string, unknown>)[section];
|
|
||||||
const b = (base as unknown as Record<string, unknown>)[section];
|
|
||||||
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
||||||
return { ...(b as object), ...(a as object) };
|
|
||||||
}
|
|
||||||
return a ?? b;
|
|
||||||
},
|
|
||||||
}) as Dict;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLocale(next: Locale): void {
|
|
||||||
locale = next;
|
|
||||||
storage()?.setItem(STORAGE_KEY, next);
|
|
||||||
syncLangAttr();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
|
|
||||||
export function initLocale(): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const saved = storage()?.getItem(STORAGE_KEY);
|
|
||||||
if (isLocale(saved)) locale = saved;
|
|
||||||
syncLangAttr();
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncLangAttr(): void {
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
document.documentElement.lang = locale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { TOOLS } from "../registry";
|
|
||||||
import { setLocale } from "./locale.svelte";
|
|
||||||
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
|
|
||||||
import { toolSearchDoc } from "./tool-strings";
|
|
||||||
|
|
||||||
describe("normalizeForSearch", () => {
|
|
||||||
it("нижний регистр", () => {
|
|
||||||
expect(normalizeForSearch("Rotate PNG")).toBe("rotate png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ё заменяется на е", () => {
|
|
||||||
expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("диакритика снимается через NFD", () => {
|
|
||||||
expect(normalizeForSearch("café")).toBe("cafe");
|
|
||||||
expect(normalizeForSearch("Über")).toBe("uber");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("scoreDoc", () => {
|
|
||||||
const doc: SearchDoc = {
|
|
||||||
id: "rotate-free-png",
|
|
||||||
titles: ["Повернуть на произвольный угол"],
|
|
||||||
descriptions: [
|
|
||||||
"Поворот на любой угол. Холст расширяется под новые габариты.",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
it("пустой запрос даёт нейтральный балл", () => {
|
|
||||||
expect(scoreDoc(doc, "")).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("префикс названия ценнее подстроки", () => {
|
|
||||||
const prefix = scoreDoc(doc, normalizeForSearch("повер"));
|
|
||||||
const infix = scoreDoc(doc, normalizeForSearch("верну"));
|
|
||||||
expect(prefix!).toBe(100);
|
|
||||||
expect(infix!).toBeGreaterThan(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("подстрока названия ценнее совпадения в описании", () => {
|
|
||||||
const title = scoreDoc(doc, normalizeForSearch("угол"));
|
|
||||||
const desc = scoreDoc(doc, normalizeForSearch("габариты"));
|
|
||||||
expect(title!).toBeGreaterThan(desc!);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("нет совпадения — null", () => {
|
|
||||||
expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("запрос с опечаткой ё/е всё равно находит", () => {
|
|
||||||
expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("кросс-языковой поиск на реальном реестре", () => {
|
|
||||||
it("английский запрос находит инструмент при русской локали", () => {
|
|
||||||
setLocale("ru");
|
|
||||||
const hits = TOOLS.filter((tool) => {
|
|
||||||
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch("rotate"));
|
|
||||||
return s !== null && s > 0;
|
|
||||||
}).map((tool) => tool.id);
|
|
||||||
expect(hits).toContain("rotate-png");
|
|
||||||
expect(hits).toContain("rotate-free-png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("русский запрос находит инструмент при английской локали", () => {
|
|
||||||
setLocale("en");
|
|
||||||
try {
|
|
||||||
for (const q of ["повернуть", "пово"]) {
|
|
||||||
const hits = TOOLS.filter((tool) => {
|
|
||||||
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
|
|
||||||
return s !== null && s > 0;
|
|
||||||
}).map((tool) => tool.id);
|
|
||||||
expect(hits, `запрос «${q}» при en-локали`).toContain("rotate-png");
|
|
||||||
expect(hits, `запрос «${q}» при en-локали`).toContain(
|
|
||||||
"rotate-free-png",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLocale("ru");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("поиск работает и по описанию, не только по названию", () => {
|
|
||||||
setLocale("en");
|
|
||||||
try {
|
|
||||||
const hits = TOOLS.filter((tool) => {
|
|
||||||
const s = scoreDoc(
|
|
||||||
toolSearchDoc(tool),
|
|
||||||
normalizeForSearch("полупрозрачные"),
|
|
||||||
);
|
|
||||||
return s !== null && s > 0;
|
|
||||||
}).map((tool) => tool.id);
|
|
||||||
expect(hits).toContain("png-is-transparent");
|
|
||||||
} finally {
|
|
||||||
setLocale("ru");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD).
|
|
||||||
*/
|
|
||||||
export function normalizeForSearch(value: string): string {
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll("ё", "е")
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/\p{M}/gu, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SearchDoc = {
|
|
||||||
id: string;
|
|
||||||
titles: string[];
|
|
||||||
descriptions: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBSEQUENCE_BASE = 20;
|
|
||||||
|
|
||||||
function bestTitleScore(titles: string[], q: string): number | null {
|
|
||||||
let best: number | null = null;
|
|
||||||
for (const raw of titles) {
|
|
||||||
const title = normalizeForSearch(raw);
|
|
||||||
if (title.startsWith(q)) return 100;
|
|
||||||
const at = title.indexOf(q);
|
|
||||||
if (at >= 0) {
|
|
||||||
const s = 80 - Math.min(at, 40);
|
|
||||||
if (best === null || s > best) best = s;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let pos = 0;
|
|
||||||
let sub = SUBSEQUENCE_BASE;
|
|
||||||
for (const ch of q) {
|
|
||||||
pos = title.indexOf(ch, pos);
|
|
||||||
if (pos < 0) {
|
|
||||||
sub = -1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
sub -= 1;
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
if (sub >= 0 && (best === null || sub > best)) best = sub;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Скоринг документа против нормализованного запроса.
|
|
||||||
* Пустой запрос — нейтральный балл; отсутствие совпадения — null.
|
|
||||||
*/
|
|
||||||
export function scoreDoc(
|
|
||||||
doc: SearchDoc,
|
|
||||||
normalizedQuery: string,
|
|
||||||
): number | null {
|
|
||||||
if (normalizedQuery.length === 0) return 1;
|
|
||||||
if (normalizeForSearch(doc.id).includes(normalizedQuery)) {
|
|
||||||
const at = normalizeForSearch(doc.id).indexOf(normalizedQuery);
|
|
||||||
const byId = 60 - Math.min(at, 40);
|
|
||||||
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
|
|
||||||
if (byTitle !== null && byTitle >= 80) return byTitle;
|
|
||||||
return Math.max(byId, byTitle ?? -1);
|
|
||||||
}
|
|
||||||
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
|
|
||||||
if (byTitle !== null && byTitle >= 20) return byTitle;
|
|
||||||
for (const raw of doc.descriptions) {
|
|
||||||
if (normalizeForSearch(raw).includes(normalizedQuery)) return 30;
|
|
||||||
}
|
|
||||||
return byTitle !== null && byTitle > 0 ? byTitle : null;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { isChainable, TOOLS } from "../registry";
|
|
||||||
import { normalizeForSearch, scoreDoc } from "./matching";
|
|
||||||
import { toolSearchDoc } from "./tool-strings";
|
|
||||||
|
|
||||||
function hits(q: string): string[] {
|
|
||||||
const nq = normalizeForSearch(q);
|
|
||||||
return TOOLS.filter((t) => {
|
|
||||||
const s = scoreDoc(toolSearchDoc(t), nq);
|
|
||||||
return s !== null && s > 0;
|
|
||||||
}).map((t) => t.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("полнота поиска", () => {
|
|
||||||
it("генератор (не chainable) находится поиском — раньше отфильтровывался", () => {
|
|
||||||
expect(isChainable(TOOLS.find((t) => t.id === "color-wheel-png")!)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(hits("color wheel")).toContain("color-wheel-png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("анализатор-маска тоже ищется", () => {
|
|
||||||
expect(hits("уникальных цветов")).toContain("unique-color-mask-png");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { TOOLS } from "../registry";
|
|
||||||
import { LOCALE_TAGS } from "./dict";
|
|
||||||
import { setLocale } from "./locale.svelte";
|
|
||||||
import { normalizeForSearch, scoreDoc } from "./matching";
|
|
||||||
import { t } from "./t";
|
|
||||||
import { toolSearchDoc } from "./tool-strings";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
setLocale("ru");
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Смоук §6 i18n", () => {
|
|
||||||
it("1. html lang следует за локалью", () => {
|
|
||||||
const doc = { documentElement: { lang: "" } };
|
|
||||||
vi.stubGlobal("document", doc);
|
|
||||||
setLocale("en");
|
|
||||||
expect(doc.documentElement.lang).toBe("en");
|
|
||||||
setLocale("ru");
|
|
||||||
expect(doc.documentElement.lang).toBe("ru");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("3. Ключевые секции переведены без смеси языков", () => {
|
|
||||||
const samples: Array<[string, string, string]> = [
|
|
||||||
["header.workspace", "Рабочая область", "Workspace"],
|
|
||||||
["catalog.heading", "Каталог инструментов", "Tool catalog"],
|
|
||||||
[
|
|
||||||
"home.heroTitle",
|
|
||||||
"Что делаем с изображением?",
|
|
||||||
"What do you want to do",
|
|
||||||
],
|
|
||||||
["chain.inputLegend", "Вход", "Input"],
|
|
||||||
["resultCard.nextTool", "Следующий инструмент", "Next tool"],
|
|
||||||
["download.busy", "Готовим файл", "Preparing file"],
|
|
||||||
];
|
|
||||||
for (const [key, ruPart, enPart] of samples) {
|
|
||||||
setLocale("ru");
|
|
||||||
expect(t(key), key + " @ru").toContain(ruPart);
|
|
||||||
setLocale("en");
|
|
||||||
expect(t(key), key + " @en").toContain(enPart);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("5. Ошибки с vars локализуются на оба языка", () => {
|
|
||||||
setLocale("ru");
|
|
||||||
expect(t("errors.badHex", { value: "#zz" })).toBe(
|
|
||||||
'Некорректный HEX-цвет: "#zz"',
|
|
||||||
);
|
|
||||||
expect(t("errors.toolNotFound", { id: "x" })).toContain("не найден");
|
|
||||||
setLocale("en");
|
|
||||||
expect(t("errors.badHex", { value: "#zz" })).toBe(
|
|
||||||
'Invalid HEX color: "#zz"',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("6. Поиск кросс-языковой в обе стороны", () => {
|
|
||||||
const ids = (q: string) =>
|
|
||||||
TOOLS.filter((tool) => {
|
|
||||||
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
|
|
||||||
return s !== null && s > 0;
|
|
||||||
}).map((tool) => tool.id);
|
|
||||||
|
|
||||||
setLocale("ru");
|
|
||||||
let hits = ids("rotate");
|
|
||||||
expect(hits).toContain("rotate-png");
|
|
||||||
hits = ids("повер");
|
|
||||||
expect(hits).toContain("rotate-png");
|
|
||||||
|
|
||||||
setLocale("en");
|
|
||||||
hits = ids("пово");
|
|
||||||
expect(hits).toContain("rotate-free-png");
|
|
||||||
hits = ids("rotate");
|
|
||||||
expect(hits).toContain("rotate-png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("6b. Ё не мешает совпадению", () => {
|
|
||||||
const blackWhite = TOOLS.find((tool) => tool.id === "black-and-white-png")!;
|
|
||||||
setLocale("ru");
|
|
||||||
const hit = scoreDoc(
|
|
||||||
toolSearchDoc(blackWhite),
|
|
||||||
normalizeForSearch("ЧЁРНО"),
|
|
||||||
);
|
|
||||||
expect(hit).not.toBeNull();
|
|
||||||
const withoutYo = scoreDoc(
|
|
||||||
toolSearchDoc(blackWhite),
|
|
||||||
normalizeForSearch("черно"),
|
|
||||||
);
|
|
||||||
expect(withoutYo).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("7. Теги локалей для форматирования чисел корректны", () => {
|
|
||||||
expect(LOCALE_TAGS.ru).toBe("ru-RU");
|
|
||||||
expect(LOCALE_TAGS.en).toBe("en-US");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { getMergedDict } from "./locale.svelte";
|
|
||||||
|
|
||||||
export function interpolate(
|
|
||||||
template: string,
|
|
||||||
vars?: Record<string, string | number>,
|
|
||||||
): string {
|
|
||||||
if (!vars) return template;
|
|
||||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
|
||||||
name in vars ? String(vars[name]) : match,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
|
|
||||||
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
|
|
||||||
*/
|
|
||||||
export function t(
|
|
||||||
path: string,
|
|
||||||
vars?: Record<string, string | number>,
|
|
||||||
): string {
|
|
||||||
let node: unknown = getMergedDict();
|
|
||||||
for (const part of path.split(".")) {
|
|
||||||
if (
|
|
||||||
node &&
|
|
||||||
typeof node === "object" &&
|
|
||||||
part in (node as Record<string, unknown>)
|
|
||||||
) {
|
|
||||||
node = (node as Record<string, unknown>)[part];
|
|
||||||
} else {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return typeof node === "string" ? interpolate(node, vars) : path;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
|
|
||||||
import { getMergedDict } from "./locale.svelte";
|
|
||||||
import type { SearchDoc } from "./matching";
|
|
||||||
import { ru } from "./ru";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Строки инструмента на активной локали.
|
|
||||||
* Русский текст живёт в самом реестре (базовая локаль),
|
|
||||||
* переводы — в секции tools словарей; отсутствие ключа даёт фолбэк на реестр.
|
|
||||||
*/
|
|
||||||
export function toolTitle(tool: ToolEntry): string {
|
|
||||||
return getMergedDict().tools[tool.id]?.title ?? tool.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toolDescription(tool: ToolEntry): string {
|
|
||||||
return getMergedDict().tools[tool.id]?.description ?? tool.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paramLabel(tool: ToolEntry, param: ParamDef): string {
|
|
||||||
const viaDict = getMergedDict().tools[tool.id]?.params?.[param.id];
|
|
||||||
if (viaDict) return viaDict;
|
|
||||||
const viaDef = param as { label?: string };
|
|
||||||
return viaDef.label ?? param.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionLabel(
|
|
||||||
tool: ToolEntry,
|
|
||||||
param: ParamDef,
|
|
||||||
value: string,
|
|
||||||
): string {
|
|
||||||
const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value];
|
|
||||||
if (viaDict) return viaDict;
|
|
||||||
if (param.type === "select") {
|
|
||||||
return param.options.find((o) => o.value === value)?.label ?? value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dedupe(values: string[]): string[] {
|
|
||||||
return [...new Set(values.filter((v) => v.length > 0))];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Поисковый документ инструмента: строки активной локали, русского и английского
|
|
||||||
* (реестр) вместе — запрос находит инструмент по любому из языков независимо
|
|
||||||
* от того, какой сейчас включён.
|
|
||||||
*/
|
|
||||||
export function toolSearchDoc(tool: ToolEntry): SearchDoc {
|
|
||||||
const active = getMergedDict().tools[tool.id];
|
|
||||||
return {
|
|
||||||
id: tool.id,
|
|
||||||
titles: dedupe([
|
|
||||||
active?.title ?? "",
|
|
||||||
ru.tools[tool.id]?.title ?? "",
|
|
||||||
tool.title,
|
|
||||||
]),
|
|
||||||
descriptions: dedupe([
|
|
||||||
active?.description ?? "",
|
|
||||||
ru.tools[tool.id]?.description ?? "",
|
|
||||||
tool.description,
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { TOOLS } from "../registry";
|
|
||||||
import { en } from "./en";
|
|
||||||
import { ru } from "./ru";
|
|
||||||
|
|
||||||
describe("секция tools словарей покрывает реестр", () => {
|
|
||||||
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
const strings = ru.tools[tool.id];
|
|
||||||
expect(strings, `нет перевода для ${tool.id}`).toBeDefined();
|
|
||||||
expect(strings?.title, `${tool.id}: title`).toBeTruthy();
|
|
||||||
expect(strings?.description, `${tool.id}: description`).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("переведены подписи всех параметров", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
const params = ru.tools[tool.id]?.params ?? {};
|
|
||||||
for (const param of tool.params) {
|
|
||||||
const label = params[param.id];
|
|
||||||
expect(
|
|
||||||
label,
|
|
||||||
`${tool.id}.${param.id}: нет перевода подписи`,
|
|
||||||
).toBeDefined();
|
|
||||||
expect(label?.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("переведены подписи всех опций select", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
const options = ru.tools[tool.id]?.options ?? {};
|
|
||||||
for (const param of tool.params) {
|
|
||||||
if (param.type !== "select") continue;
|
|
||||||
const labels = options[param.id] ?? {};
|
|
||||||
for (const option of param.options) {
|
|
||||||
const label = labels[option.value];
|
|
||||||
expect(
|
|
||||||
label,
|
|
||||||
`${tool.id}.${param.id}[${option.value}]: нет перевода`,
|
|
||||||
).toBeDefined();
|
|
||||||
expect(label.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("в словарях нет лишних ключей инструментов и параметров", () => {
|
|
||||||
const ids = new Set(TOOLS.map((tool) => tool.id));
|
|
||||||
for (const dict of [ru.tools, en.tools]) {
|
|
||||||
for (const id of Object.keys(dict)) {
|
|
||||||
expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true);
|
|
||||||
const tool = TOOLS.find((t) => t.id === id)!;
|
|
||||||
const paramIds = new Set(tool.params.map((p) => p.id));
|
|
||||||
for (const paramId of Object.keys(dict[id].params ?? {})) {
|
|
||||||
expect(
|
|
||||||
paramIds.has(paramId),
|
|
||||||
`${id}: лишний параметр ${paramId}`,
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
const selectIds = new Set(
|
|
||||||
tool.params.filter((p) => p.type === "select").map((p) => p.id),
|
|
||||||
);
|
|
||||||
for (const paramId of Object.keys(dict[id].options ?? {})) {
|
|
||||||
expect(
|
|
||||||
selectIds.has(paramId),
|
|
||||||
`${id}: options у не-select параметра ${paramId}`,
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("тексты-результаты анализаторов переведены в обоих словарях", () => {
|
|
||||||
const expectations: Array<[string, string[]]> = [
|
|
||||||
["png-is-grayscale", ["grayscaleYes", "grayscaleNo"]],
|
|
||||||
["png-is-transparent", ["transparentYes", "transparentNo"]],
|
|
||||||
[
|
|
||||||
"png-orientation",
|
|
||||||
["orientationPortrait", "orientationLandscape", "orientationSquare"],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
for (const [id, keys] of expectations) {
|
|
||||||
for (const key of keys) {
|
|
||||||
expect(en.tools[id]?.results?.[key], `${id}.${key} (en)`).toBeDefined();
|
|
||||||
expect(ru.tools[id]?.results?.[key], `${id}.${key} (ru)`).toBeDefined();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ToolError } from "../core/errors";
|
|
||||||
|
|
||||||
export function num(params: Record<string, unknown>, id: string): number {
|
|
||||||
const v = params[id];
|
|
||||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
||||||
throw new ToolError("errors.paramNumber", { id });
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function str(params: Record<string, unknown>, id: string): string {
|
|
||||||
const v = params[id];
|
|
||||||
if (typeof v !== "string") {
|
|
||||||
throw new ToolError("errors.paramString", { id });
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bool(params: Record<string, unknown>, id: string): boolean {
|
|
||||||
const v = params[id];
|
|
||||||
if (typeof v !== "boolean") {
|
|
||||||
throw new ToolError("errors.paramBool", { id });
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { CATEGORIES } from "./categories";
|
|
||||||
import { TOOL_ICONS } from "./tools/tool-icons";
|
|
||||||
import {
|
|
||||||
defaultParams,
|
|
||||||
getTool,
|
|
||||||
outputOf,
|
|
||||||
sanitizeParams,
|
|
||||||
TOOLS,
|
|
||||||
type ParamDef,
|
|
||||||
type ToolEntry,
|
|
||||||
} from "./registry";
|
|
||||||
|
|
||||||
describe("реестр инструментов", () => {
|
|
||||||
it("id уникальны и в kebab-case", () => {
|
|
||||||
const ids = TOOLS.map((t) => t.id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
for (const id of ids) {
|
|
||||||
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(TOOLS.map((t) => [t.id, t] as const))(
|
|
||||||
"%s: категория валидна",
|
|
||||||
(_, tool) => {
|
|
||||||
expect(CATEGORIES).toContain(tool.category);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(TOOLS.map((t) => [t.id, t] as const))(
|
|
||||||
"%s: исполнители определены",
|
|
||||||
(_, tool) => {
|
|
||||||
if (tool.resultType === "text") {
|
|
||||||
expect(
|
|
||||||
typeof tool.toText === "function" ||
|
|
||||||
typeof tool.textToText === "function",
|
|
||||||
).toBe(true);
|
|
||||||
} else if (tool.sourceMode === "none") {
|
|
||||||
expect(typeof tool.generate).toBe("function");
|
|
||||||
} else {
|
|
||||||
expect(typeof tool.run).toBe("function");
|
|
||||||
}
|
|
||||||
if (tool.preview) {
|
|
||||||
expect(typeof tool.preview).toBe("function");
|
|
||||||
}
|
|
||||||
expect(tool.title.length).toBeGreaterThan(0);
|
|
||||||
expect(tool.description.length).toBeGreaterThan(0);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("у select дефолт входит в options, у number/slider дефолт в диапазоне", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
for (const param of tool.params) {
|
|
||||||
if (param.type === "select") {
|
|
||||||
expect(param.options.map((o) => o.value)).toContain(param.default);
|
|
||||||
expect(param.options.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
if (param.type === "number") {
|
|
||||||
expect(param.min === undefined || param.default >= param.min).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(param.max === undefined || param.default <= param.max).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (param.type === "slider") {
|
|
||||||
expect(param.min).toBeLessThan(param.max);
|
|
||||||
expect(param.default).toBeGreaterThanOrEqual(param.min);
|
|
||||||
expect(param.default).toBeLessThanOrEqual(param.max);
|
|
||||||
}
|
|
||||||
if (param.type === "color") {
|
|
||||||
expect(param.default).toMatch(/^#[0-9a-f]{6}$/i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("id параметров уникальны внутри инструмента", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
const ids = tool.params.map((p) => p.id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("qualityParamId ссылается на существующий числовой параметр", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
const output = outputOf(tool);
|
|
||||||
if (output?.qualityParamId) {
|
|
||||||
const param = tool.params.find(
|
|
||||||
(p): p is Extract<ParamDef, { type: "number" | "slider" }> =>
|
|
||||||
p.id === output.qualityParamId,
|
|
||||||
);
|
|
||||||
expect(param).toBeDefined();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("png-info не имеет формата вывода, конвертеры имеют jpeg/webp, остальные — png", () => {
|
|
||||||
expect(outputOf(getToolOrThrow("png-info"))).toBeUndefined();
|
|
||||||
expect(outputOf(getToolOrThrow("convert-png-to-jpg"))?.mime).toBe(
|
|
||||||
"image/jpeg",
|
|
||||||
);
|
|
||||||
expect(outputOf(getToolOrThrow("convert-png-to-webp"))?.mime).toBe(
|
|
||||||
"image/webp",
|
|
||||||
);
|
|
||||||
expect(outputOf(getToolOrThrow("grayscale-png"))).toEqual({
|
|
||||||
mime: "image/png",
|
|
||||||
ext: "png",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaultParams собирает значения по умолчанию", () => {
|
|
||||||
const resize = getToolOrThrow("resize-png");
|
|
||||||
expect(defaultParams(resize)).toEqual({
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
keepAspect: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("словарь иконок покрывает все инструменты", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
expect(TOOL_ICONS[tool.id]).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("у каждого инструмента популярность в диапазоне 0..100 и ключ иконки", () => {
|
|
||||||
for (const tool of TOOLS) {
|
|
||||||
expect(tool.popularity).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(tool.popularity).toBeLessThanOrEqual(100);
|
|
||||||
expect(typeof tool.icon).toBe("string");
|
|
||||||
expect(tool.icon!.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sanitizeParams", () => {
|
|
||||||
it("заменяет пустые и невалидные числа на дефолт", () => {
|
|
||||||
const resize = getToolOrThrow("resize-png");
|
|
||||||
expect(
|
|
||||||
sanitizeParams(resize, {
|
|
||||||
width: undefined,
|
|
||||||
height: null,
|
|
||||||
keepAspect: true,
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
keepAspect: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("клампит числа в диапазон параметра", () => {
|
|
||||||
const bc = getToolOrThrow("adjust-brightness-contrast-png");
|
|
||||||
expect(sanitizeParams(bc, { brightness: 5000, contrast: -999 })).toEqual({
|
|
||||||
brightness: 100,
|
|
||||||
contrast: -100,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("возвращает дефолт для невалидных select, checkbox и color", () => {
|
|
||||||
const rotate = getToolOrThrow("rotate-png");
|
|
||||||
expect(sanitizeParams(rotate, { angle: "45" })).toEqual({ angle: "90" });
|
|
||||||
const resize = getToolOrThrow("resize-png");
|
|
||||||
expect(
|
|
||||||
sanitizeParams(resize, { width: 10, height: 10, keepAspect: "yes" }),
|
|
||||||
).toEqual({
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
keepAspect: true,
|
|
||||||
});
|
|
||||||
const jpg = getToolOrThrow("convert-png-to-jpg");
|
|
||||||
expect(sanitizeParams(jpg, { background: "red", quality: 50 })).toEqual({
|
|
||||||
background: "#ffffff",
|
|
||||||
quality: 50,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run инструмента resize-png", () => {
|
|
||||||
const img = {
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
data: new Uint8ClampedArray(100 * 50 * 4),
|
|
||||||
};
|
|
||||||
const runResize = async (params: Record<string, unknown>) =>
|
|
||||||
getToolOrThrow("resize-png").run!(img, params);
|
|
||||||
|
|
||||||
it("keepAspect + одна сторона — вторая считается по пропорции", async () => {
|
|
||||||
const out = await runResize({ width: 200, height: 0, keepAspect: true });
|
|
||||||
expect(out.width).toBe(200);
|
|
||||||
expect(out.height).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keepAspect + обе стороны — вписывание в размеры без искажения", async () => {
|
|
||||||
const out = await runResize({ width: 50, height: 50, keepAspect: true });
|
|
||||||
expect(out.width).toBe(50);
|
|
||||||
expect(out.height).toBe(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("без keepAspect — обе стороны как задано", async () => {
|
|
||||||
const out = await runResize({ width: 30, height: 40, keepAspect: false });
|
|
||||||
expect(out.width).toBe(30);
|
|
||||||
expect(out.height).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("обе стороны 0 — человекочитаемая ошибка", async () => {
|
|
||||||
await expect(
|
|
||||||
runResize({ width: 0, height: 0, keepAspect: true }),
|
|
||||||
).rejects.toThrow("errors.resizeSize");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getToolOrThrow(id: string): ToolEntry {
|
|
||||||
const tool = getTool(id);
|
|
||||||
if (!tool) throw new Error(`Инструмент "${id}" не найден`);
|
|
||||||
return tool;
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import type { CategoryId } from "./categories";
|
|
||||||
import type { OutputMime } from "../core/io";
|
|
||||||
import type { PixelImage } from "../core/types";
|
|
||||||
|
|
||||||
import { alphaEntries } from "./registry/alpha";
|
|
||||||
import { analyzeEntries } from "./registry/analyze";
|
|
||||||
import { colorEntries } from "./registry/color";
|
|
||||||
import { convertEntries } from "./registry/convert";
|
|
||||||
import { filterEntries } from "./registry/filters";
|
|
||||||
import { generateEntries } from "./registry/generate";
|
|
||||||
import { geometryEntries } from "./registry/geometry";
|
|
||||||
import { textEntries } from "./registry/text";
|
|
||||||
|
|
||||||
// TODO: remove convert tools - move to load image \ download button flow
|
|
||||||
|
|
||||||
export type ParamDef =
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type: "number";
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
default: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type: "slider";
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step?: number;
|
|
||||||
default: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type: "select";
|
|
||||||
options: { value: string; label: string }[];
|
|
||||||
default: string;
|
|
||||||
}
|
|
||||||
| { id: string; label: string; type: "checkbox"; default: boolean }
|
|
||||||
| { id: string; label: string; type: "color"; default: string }
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type: "text";
|
|
||||||
default: string;
|
|
||||||
placeholder?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OutputFormat = {
|
|
||||||
mime: OutputMime;
|
|
||||||
ext: string;
|
|
||||||
qualityParamId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SourceMode = "file" | "none" | "text";
|
|
||||||
|
|
||||||
export type ToolEntry<P = Record<string, unknown>> = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
category: CategoryId;
|
|
||||||
sourceMode?: SourceMode;
|
|
||||||
params: ParamDef[];
|
|
||||||
run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
|
||||||
generate?: (params: P) => Promise<PixelImage> | PixelImage;
|
|
||||||
toText?: (img: PixelImage, params: P) => Promise<string> | string;
|
|
||||||
runFromText?: (text: string, params: P) => Promise<PixelImage> | PixelImage;
|
|
||||||
textToText?: (text: string) => Promise<string> | string;
|
|
||||||
preview?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
|
||||||
popularity?: number;
|
|
||||||
icon?: string;
|
|
||||||
resultType?: "image" | "info" | "text";
|
|
||||||
output?: OutputFormat;
|
|
||||||
domOnly?: boolean;
|
|
||||||
needsOverlaySource?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PNG_OUTPUT: OutputFormat = { mime: "image/png", ext: "png" };
|
|
||||||
|
|
||||||
export function isChainable(tool: ToolEntry): boolean {
|
|
||||||
return (
|
|
||||||
(tool.resultType ?? "image") === "image" &&
|
|
||||||
(!tool.sourceMode || tool.sourceMode === "file")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const _convert = convertEntries();
|
|
||||||
const _geometry = geometryEntries();
|
|
||||||
const _alpha = alphaEntries();
|
|
||||||
const _color = colorEntries();
|
|
||||||
const _analyze = analyzeEntries();
|
|
||||||
const _generate = generateEntries();
|
|
||||||
const _text = textEntries();
|
|
||||||
const _filters = filterEntries();
|
|
||||||
|
|
||||||
export const TOOLS: ToolEntry[] = [
|
|
||||||
..._color.slice(0, 6),
|
|
||||||
..._analyze.slice(0, 6),
|
|
||||||
..._convert.slice(0, 16),
|
|
||||||
..._geometry.slice(0, 8),
|
|
||||||
..._alpha.slice(0, 4),
|
|
||||||
..._geometry.slice(8, 9),
|
|
||||||
..._filters.slice(0, 2),
|
|
||||||
..._color.slice(6, 17),
|
|
||||||
..._convert.slice(16, 18),
|
|
||||||
..._alpha.slice(4, 20),
|
|
||||||
..._analyze.slice(6, 9),
|
|
||||||
..._generate,
|
|
||||||
..._text,
|
|
||||||
..._alpha.slice(20, 21),
|
|
||||||
..._analyze.slice(9, 10),
|
|
||||||
..._geometry.slice(9, 18),
|
|
||||||
..._filters.slice(2, 8),
|
|
||||||
..._color.slice(17, 25),
|
|
||||||
..._convert.slice(18, 20),
|
|
||||||
..._analyze.slice(10, 11),
|
|
||||||
..._convert.slice(20, 21),
|
|
||||||
..._analyze.slice(11, 13),
|
|
||||||
];
|
|
||||||
|
|
||||||
import { TOOL_POPULARITY } from "./tools/tool-popularity";
|
|
||||||
|
|
||||||
for (const entry of TOOLS) {
|
|
||||||
entry.icon = entry.id;
|
|
||||||
entry.popularity = TOOL_POPULARITY[entry.id] ?? 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTool(id: string): ToolEntry | undefined {
|
|
||||||
return TOOLS.find((tool) => tool.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultParams(tool: ToolEntry): Record<string, unknown> {
|
|
||||||
return Object.fromEntries(tool.params.map((p) => [p.id, p.default]));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeParams(
|
|
||||||
tool: ToolEntry,
|
|
||||||
values: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const param of tool.params) {
|
|
||||||
const raw = values[param.id];
|
|
||||||
switch (param.type) {
|
|
||||||
case "number":
|
|
||||||
case "slider": {
|
|
||||||
const n =
|
|
||||||
typeof raw === "number" && Number.isFinite(raw) ? raw : param.default;
|
|
||||||
out[param.id] = clampRange(n, param.min, param.max);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "select":
|
|
||||||
out[param.id] =
|
|
||||||
typeof raw === "string" && param.options.some((o) => o.value === raw)
|
|
||||||
? raw
|
|
||||||
: param.default;
|
|
||||||
break;
|
|
||||||
case "checkbox":
|
|
||||||
out[param.id] = typeof raw === "boolean" ? raw : param.default;
|
|
||||||
break;
|
|
||||||
case "color":
|
|
||||||
out[param.id] =
|
|
||||||
typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw)
|
|
||||||
? raw
|
|
||||||
: param.default;
|
|
||||||
break;
|
|
||||||
case "text":
|
|
||||||
out[param.id] = typeof raw === "string" ? raw : param.default;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampRange(value: number, min?: number, max?: number): number {
|
|
||||||
if (min !== undefined && value < min) return min;
|
|
||||||
if (max !== undefined && value > max) return max;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function outputOf(tool: ToolEntry): OutputFormat | undefined {
|
|
||||||
if (tool.resultType === "info") return undefined;
|
|
||||||
return tool.output ?? PNG_OUTPUT;
|
|
||||||
}
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import { ToolError } from "../../core/errors";
|
|
||||||
import {
|
|
||||||
colorMask,
|
|
||||||
extractAlphaMask,
|
|
||||||
flattenOntoColor,
|
|
||||||
hardenAlpha,
|
|
||||||
invertAlpha,
|
|
||||||
removeColorToAlpha,
|
|
||||||
roundCorners,
|
|
||||||
setAlphaChannel,
|
|
||||||
} from "../../core/alpha";
|
|
||||||
import { backgroundMaskPreview, removeBackground } from "../../core/background";
|
|
||||||
import {
|
|
||||||
closingImage,
|
|
||||||
contourImage,
|
|
||||||
dilateImage,
|
|
||||||
erodeImage,
|
|
||||||
openingImage,
|
|
||||||
strokeImage,
|
|
||||||
} from "../../core/morphology";
|
|
||||||
import { featherAlpha, defringe } from "../../core/pixel-fx";
|
|
||||||
import { drawImageWatermark } from "../../core/domText";
|
|
||||||
import { getOverlay } from "../tools/overlay-store.svelte";
|
|
||||||
import {
|
|
||||||
renderShape,
|
|
||||||
circleTest,
|
|
||||||
boxTest,
|
|
||||||
starTest,
|
|
||||||
wavyTest,
|
|
||||||
} from "../../core/shapes";
|
|
||||||
import type { Position9 } from "../../core/textdraw";
|
|
||||||
import { num, str } from "../registry-helpers";
|
|
||||||
|
|
||||||
export function alphaEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "circle-mask-png",
|
|
||||||
title: "Circle Mask PNG",
|
|
||||||
description:
|
|
||||||
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "size",
|
|
||||||
label: "Diameter, % of smaller side",
|
|
||||||
type: "slider",
|
|
||||||
min: 20,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetX",
|
|
||||||
label: "Offset X, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetY",
|
|
||||||
label: "Offset Y, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
renderShape(
|
|
||||||
img,
|
|
||||||
circleTest(num(p, "size") / 200),
|
|
||||||
num(p, "offsetX") / 100,
|
|
||||||
num(p, "offsetY") / 100,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "square-mask-png",
|
|
||||||
title: "Square Mask PNG",
|
|
||||||
description:
|
|
||||||
"Cuts the image into a rectangle with sides as a share of the smaller side.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "widthPct",
|
|
||||||
label: "Width, % of smaller side",
|
|
||||||
type: "slider",
|
|
||||||
min: 10,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "heightPct",
|
|
||||||
label: "Height, % of smaller side",
|
|
||||||
type: "slider",
|
|
||||||
min: 10,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetX",
|
|
||||||
label: "Offset X, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetY",
|
|
||||||
label: "Offset Y, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
renderShape(
|
|
||||||
img,
|
|
||||||
boxTest(num(p, "widthPct") / 200, num(p, "heightPct") / 200),
|
|
||||||
num(p, "offsetX") / 100,
|
|
||||||
num(p, "offsetY") / 100,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "star-mask-png",
|
|
||||||
title: "Star Mask PNG",
|
|
||||||
description:
|
|
||||||
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "points",
|
|
||||||
label: "Points",
|
|
||||||
type: "slider",
|
|
||||||
min: 3,
|
|
||||||
max: 12,
|
|
||||||
step: 1,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "innerRadius",
|
|
||||||
label: "Inner radius, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 10,
|
|
||||||
max: 90,
|
|
||||||
step: 1,
|
|
||||||
default: 45,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "size",
|
|
||||||
label: "Outer radius, % of smaller side",
|
|
||||||
type: "slider",
|
|
||||||
min: 20,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rotation",
|
|
||||||
label: "Rotation, °",
|
|
||||||
type: "slider",
|
|
||||||
min: -180,
|
|
||||||
max: 180,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetX",
|
|
||||||
label: "Offset X, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetY",
|
|
||||||
label: "Offset Y, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
renderShape(
|
|
||||||
img,
|
|
||||||
starTest(
|
|
||||||
num(p, "points"),
|
|
||||||
num(p, "innerRadius") / 100,
|
|
||||||
num(p, "size") / 200,
|
|
||||||
num(p, "rotation"),
|
|
||||||
),
|
|
||||||
num(p, "offsetX") / 100,
|
|
||||||
num(p, "offsetY") / 100,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "wavy-mask-png",
|
|
||||||
title: "Wavy Mask PNG",
|
|
||||||
description:
|
|
||||||
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "size",
|
|
||||||
label: "Base radius, % of smaller side",
|
|
||||||
type: "slider",
|
|
||||||
min: 20,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "amplitude",
|
|
||||||
label: "Wave amplitude, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 30,
|
|
||||||
step: 1,
|
|
||||||
default: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "waves",
|
|
||||||
label: "Waves count",
|
|
||||||
type: "slider",
|
|
||||||
min: 3,
|
|
||||||
max: 24,
|
|
||||||
step: 1,
|
|
||||||
default: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "phase",
|
|
||||||
label: "Phase, °",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 360,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetX",
|
|
||||||
label: "Offset X, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetY",
|
|
||||||
label: "Offset Y, %",
|
|
||||||
type: "slider",
|
|
||||||
min: -50,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
renderShape(
|
|
||||||
img,
|
|
||||||
wavyTest(
|
|
||||||
num(p, "size") / 200,
|
|
||||||
num(p, "amplitude") / 200,
|
|
||||||
num(p, "waves"),
|
|
||||||
num(p, "phase"),
|
|
||||||
),
|
|
||||||
num(p, "offsetX") / 100,
|
|
||||||
num(p, "offsetY") / 100,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "remove-alpha-channel-png",
|
|
||||||
title: "Remove alpha channel PNG",
|
|
||||||
description:
|
|
||||||
"Composites the image over a white background and saves without transparency.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [],
|
|
||||||
run: (img) => flattenOntoColor(img, "#ffffff"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "set-alpha-channel-png",
|
|
||||||
title: "Set alpha channel PNG",
|
|
||||||
description:
|
|
||||||
"Assigns the same opacity to all pixels; colors stay unchanged.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "percent",
|
|
||||||
label: "Opacity, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => setAlphaChannel(img, num(p, "percent")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "extract-alpha-mask-png",
|
|
||||||
title: "Extract alpha mask PNG",
|
|
||||||
description: "Turns transparency into a black-and-white opaque mask.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [],
|
|
||||||
run: (img) => extractAlphaMask(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "round-corners-png",
|
|
||||||
title: "Round corners PNG",
|
|
||||||
description:
|
|
||||||
"Clips corners by a radius set as a percentage of half the smaller side.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Corner radius, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => roundCorners(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "invert-alpha-png",
|
|
||||||
title: "Invert alpha PNG",
|
|
||||||
description: "Opaque areas become transparent and vice versa.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [],
|
|
||||||
run: (img) => invertAlpha(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "remove-background-png",
|
|
||||||
title: "Remove background PNG (smart)",
|
|
||||||
description:
|
|
||||||
"Removes a solid background: by color with tolerance, outer regions from the edges only, or every matching pixel. Can smooth the boundary.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Background color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tolerance",
|
|
||||||
label: "Similarity tolerance, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "outerOnly",
|
|
||||||
label: "Outer regions only",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "smooth",
|
|
||||||
label: "Edge smoothing, passes",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 8,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
removeBackground(img, {
|
|
||||||
color: str(p, "color"),
|
|
||||||
tolerancePercent: num(p, "tolerance"),
|
|
||||||
outerOnly: p["outerOnly"] === true,
|
|
||||||
smoothPasses: num(p, "smooth"),
|
|
||||||
}),
|
|
||||||
preview: (img, p) =>
|
|
||||||
backgroundMaskPreview(img, {
|
|
||||||
color: str(p, "color"),
|
|
||||||
tolerancePercent: num(p, "tolerance"),
|
|
||||||
outerOnly: p["outerOnly"] === true,
|
|
||||||
smoothPasses: num(p, "smooth"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "add-stroke-png",
|
|
||||||
title: "Outline PNG",
|
|
||||||
description:
|
|
||||||
"Adds a colored ring outline around the opaque content with the chosen thickness.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Outline color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ff0000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "thickness",
|
|
||||||
label: "Thickness, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 10,
|
|
||||||
step: 1,
|
|
||||||
default: 3,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => strokeImage(img, num(p, "thickness"), str(p, "color")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "find-contour-png",
|
|
||||||
title: "Find contour PNG",
|
|
||||||
description:
|
|
||||||
"Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Line color",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "thickness",
|
|
||||||
label: "Line thickness, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 5,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => contourImage(img, num(p, "thickness"), str(p, "color")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "make-thicker-png",
|
|
||||||
title: "Thicken PNG",
|
|
||||||
description: "Expands opaque areas by the given number of pixels.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Amount, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 10,
|
|
||||||
step: 1,
|
|
||||||
default: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => dilateImage(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "make-thinner-png",
|
|
||||||
title: "Thin PNG",
|
|
||||||
description:
|
|
||||||
"Shrinks opaque areas — thins the strokes of text and details.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Amount, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 10,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => erodeImage(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "feather-edges-png",
|
|
||||||
title: "Feather Edges PNG",
|
|
||||||
description:
|
|
||||||
"Blurs only the alpha channel: hard cutout edges become soft and gradual, colors stay untouched.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Feather radius, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 20,
|
|
||||||
step: 1,
|
|
||||||
default: 3,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => featherAlpha(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "clean-edges-png",
|
|
||||||
title: "Clean Edges PNG (defringe)",
|
|
||||||
description:
|
|
||||||
"Replaces edge-halo colors of semi-transparent pixels with the nearest fully opaque color. Alpha stays as is.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Search radius, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 10,
|
|
||||||
step: 1,
|
|
||||||
default: 3,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => defringe(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "harden-alpha-png",
|
|
||||||
title: "Harden edges PNG",
|
|
||||||
description:
|
|
||||||
"Binarizes the alpha channel by threshold: semi-transparent pixels become either fully transparent or fully opaque.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "threshold",
|
|
||||||
label: "Alpha threshold, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => hardenAlpha(img, num(p, "threshold")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "despeckle-alpha-png",
|
|
||||||
title: "Despeckle PNG",
|
|
||||||
description:
|
|
||||||
"Opening: removes lone semi-transparent pixels and small specks.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Cleanup radius, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 3,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => openingImage(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "close-holes-png",
|
|
||||||
title: "Close holes PNG",
|
|
||||||
description: "Closing: fills lone transparent dots inside the object.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Closing radius, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 3,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => closingImage(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "remove-color-from-png",
|
|
||||||
title: "Remove color from PNG (make transparent)",
|
|
||||||
description:
|
|
||||||
"Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
|
|
||||||
category: "alpha",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "targetColor",
|
|
||||||
label: "Color to remove",
|
|
||||||
type: "color",
|
|
||||||
default: "#00ff00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tolerance",
|
|
||||||
label: "Similarity threshold, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
removeColorToAlpha(img, str(p, "targetColor"), num(p, "tolerance")),
|
|
||||||
preview: (img, p) =>
|
|
||||||
colorMask(img, str(p, "targetColor"), num(p, "tolerance")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "watermark-image-png",
|
|
||||||
title: "Watermark Image PNG",
|
|
||||||
description:
|
|
||||||
"Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.",
|
|
||||||
category: "alpha",
|
|
||||||
domOnly: true,
|
|
||||||
needsOverlaySource: true,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "scale",
|
|
||||||
label: "Mark width, % of canvas",
|
|
||||||
type: "slider",
|
|
||||||
min: 5,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "opacity",
|
|
||||||
label: "Opacity, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 5,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
default: 60,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "position",
|
|
||||||
label: "Position",
|
|
||||||
type: "select",
|
|
||||||
default: "bottom-right",
|
|
||||||
options: [
|
|
||||||
{ value: "top-left", label: "Top left" },
|
|
||||||
{ value: "top-center", label: "Top center" },
|
|
||||||
{ value: "top-right", label: "Top right" },
|
|
||||||
{ value: "middle-left", label: "Middle left" },
|
|
||||||
{ value: "center", label: "Center" },
|
|
||||||
{ value: "middle-right", label: "Middle right" },
|
|
||||||
{ value: "bottom-left", label: "Bottom left" },
|
|
||||||
{ value: "bottom-center", label: "Bottom center" },
|
|
||||||
{ value: "bottom-right", label: "Bottom right" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "margin",
|
|
||||||
label: "Margin, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 200,
|
|
||||||
step: 1,
|
|
||||||
default: 24,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => {
|
|
||||||
const mark = getOverlay();
|
|
||||||
if (!mark) throw new ToolError("errors.noWatermark");
|
|
||||||
return drawImageWatermark(img, {
|
|
||||||
mark,
|
|
||||||
scalePercent: num(p, "scale"),
|
|
||||||
opacityPercent: num(p, "opacity"),
|
|
||||||
position: str(p, "position") as Position9,
|
|
||||||
margin: num(p, "margin"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import {
|
|
||||||
extractByColor,
|
|
||||||
isGrayscaleish,
|
|
||||||
luma01,
|
|
||||||
rarityPredicate,
|
|
||||||
renderPredicateMask,
|
|
||||||
} from "../../core/masks";
|
|
||||||
import {
|
|
||||||
hasTransparency,
|
|
||||||
isGrayscale,
|
|
||||||
orientationOf,
|
|
||||||
} from "../../core/analyze";
|
|
||||||
import { looksLikePng, base64ToBytes, stripDataUri } from "../../core/textio";
|
|
||||||
import { clonePixelImage, type PixelImage } from "../../core/types";
|
|
||||||
import { encode } from "../../core/io";
|
|
||||||
import { t } from "../i18n/t";
|
|
||||||
import { num, str } from "../registry-helpers";
|
|
||||||
|
|
||||||
type MaskToolSpec = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
defaultMode: "binary" | "highlight";
|
|
||||||
predicate: (
|
|
||||||
img: PixelImage,
|
|
||||||
p: Record<string, unknown>,
|
|
||||||
) => (r: number, g: number, b: number, a: number) => boolean;
|
|
||||||
extraParams?: ToolEntry["params"];
|
|
||||||
};
|
|
||||||
|
|
||||||
const MASK_TOOLS: MaskToolSpec[] = [
|
|
||||||
{
|
|
||||||
id: "show-transparent-png",
|
|
||||||
title: "Show Transparent Areas PNG",
|
|
||||||
description:
|
|
||||||
"Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.",
|
|
||||||
defaultMode: "highlight",
|
|
||||||
predicate: (_img, _p) => (_r, _g, _b, a) => a < 255,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "show-grayscale-pixels-png",
|
|
||||||
title: "Show Grayscale Pixels PNG",
|
|
||||||
description:
|
|
||||||
"Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.",
|
|
||||||
defaultMode: "binary",
|
|
||||||
predicate: (_i, p) => (r, g, b) =>
|
|
||||||
isGrayscaleish(r, g, b, num(p, "tolerance")),
|
|
||||||
extraParams: [
|
|
||||||
{
|
|
||||||
id: "tolerance",
|
|
||||||
label: "Channel tolerance",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 64,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "show-color-pixels-png",
|
|
||||||
title: "Show Color Pixels PNG",
|
|
||||||
description:
|
|
||||||
"Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.",
|
|
||||||
defaultMode: "binary",
|
|
||||||
predicate: (_i, p) => (r, g, b) =>
|
|
||||||
!isGrayscaleish(r, g, b, num(p, "tolerance")),
|
|
||||||
extraParams: [
|
|
||||||
{
|
|
||||||
id: "tolerance",
|
|
||||||
label: "Channel tolerance",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 64,
|
|
||||||
step: 1,
|
|
||||||
default: 8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "light-pixel-mask-png",
|
|
||||||
title: "Light Pixel Mask PNG",
|
|
||||||
description: "Selects pixels brighter than the luminance threshold.",
|
|
||||||
defaultMode: "binary",
|
|
||||||
predicate: (_i, p) => (r, g, b) =>
|
|
||||||
luma01(r, g, b) >= num(p, "threshold") / 100,
|
|
||||||
extraParams: [
|
|
||||||
{
|
|
||||||
id: "threshold",
|
|
||||||
label: "Luminance threshold, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 70,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "dark-pixel-mask-png",
|
|
||||||
title: "Dark Pixel Mask PNG",
|
|
||||||
description: "Selects pixels darker than the luminance threshold.",
|
|
||||||
defaultMode: "binary",
|
|
||||||
predicate: (_i, p) => (r, g, b) =>
|
|
||||||
luma01(r, g, b) <= num(p, "threshold") / 100,
|
|
||||||
extraParams: [
|
|
||||||
{
|
|
||||||
id: "threshold",
|
|
||||||
label: "Luminance threshold, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 30,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "unique-color-mask-png",
|
|
||||||
title: "Unique Color Mask PNG",
|
|
||||||
description:
|
|
||||||
"Selects colors that occur no more than the given number of times — rare and one-off pixels.",
|
|
||||||
defaultMode: "binary",
|
|
||||||
predicate: (img, p) => rarityPredicate(img, num(p, "rarity")),
|
|
||||||
extraParams: [
|
|
||||||
{
|
|
||||||
id: "rarity",
|
|
||||||
label: "Max occurrences",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function maskEntries(): ToolEntry[] {
|
|
||||||
return MASK_TOOLS.map((spec) => ({
|
|
||||||
id: spec.id,
|
|
||||||
title: spec.title,
|
|
||||||
description: spec.description,
|
|
||||||
category: "analyze" as const,
|
|
||||||
params: [
|
|
||||||
...(spec.extraParams ?? []),
|
|
||||||
{
|
|
||||||
id: "mode",
|
|
||||||
label: "Mask mode",
|
|
||||||
type: "select" as const,
|
|
||||||
default: spec.defaultMode,
|
|
||||||
options: [
|
|
||||||
{ value: "binary", label: "Black & white mask" },
|
|
||||||
{ value: "highlight", label: "Color highlight" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Highlight color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ff00aa",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "opacity",
|
|
||||||
label: "Highlight opacity, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
default: 70,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
renderPredicateMask(img, spec.predicate(img, p), {
|
|
||||||
mode: str(p, "mode") === "highlight" ? "highlight" : "binary",
|
|
||||||
color: str(p, "color"),
|
|
||||||
opacityPercent: num(p, "opacity"),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function analyzeEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
...maskEntries(),
|
|
||||||
{
|
|
||||||
id: "extract-color-from-png",
|
|
||||||
title: "Extract Color from PNG",
|
|
||||||
description:
|
|
||||||
"Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
|
|
||||||
category: "analyze",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Color to keep",
|
|
||||||
type: "color",
|
|
||||||
default: "#00ff88",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tolerance",
|
|
||||||
label: "Similarity tolerance, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
extractByColor(img, str(p, "color"), num(p, "tolerance")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "verify-is-png",
|
|
||||||
title: "Verify If Image Is a PNG",
|
|
||||||
description:
|
|
||||||
"Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.",
|
|
||||||
category: "analyze",
|
|
||||||
sourceMode: "text",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
textToText: (text) =>
|
|
||||||
looksLikePng(base64ToBytes(stripDataUri(text)))
|
|
||||||
? "verifyYes"
|
|
||||||
: "verifyNo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-info",
|
|
||||||
title: "PNG info",
|
|
||||||
description:
|
|
||||||
"Shows dimensions, alpha presence and the number of unique colors of the uploaded image.",
|
|
||||||
category: "analyze",
|
|
||||||
params: [],
|
|
||||||
resultType: "info",
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-is-grayscale",
|
|
||||||
title: "Check: is PNG grayscale?",
|
|
||||||
description: "Reports whether the image consists only of shades of gray.",
|
|
||||||
category: "analyze",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-file-size",
|
|
||||||
title: "PNG File Size",
|
|
||||||
description:
|
|
||||||
"Encodes the image as PNG and reports the resulting file size.",
|
|
||||||
category: "analyze",
|
|
||||||
domOnly: true,
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: async (img) => {
|
|
||||||
const blob = await encode(img, "image/png");
|
|
||||||
const kb = blob.size / 1024;
|
|
||||||
const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1);
|
|
||||||
return t("tools.png-file-size.results.line", {
|
|
||||||
kb: kbText,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-is-transparent",
|
|
||||||
title: "Check: is PNG transparent?",
|
|
||||||
description:
|
|
||||||
"Reports whether the image contains transparent or semi-transparent pixels.",
|
|
||||||
category: "analyze",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) =>
|
|
||||||
hasTransparency(img) ? "transparentYes" : "transparentNo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-orientation",
|
|
||||||
title: "PNG orientation",
|
|
||||||
description: "Reports whether it is portrait, landscape or square.",
|
|
||||||
category: "analyze",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) => {
|
|
||||||
switch (orientationOf(img)) {
|
|
||||||
case "portrait":
|
|
||||||
return "orientationPortrait";
|
|
||||||
case "landscape":
|
|
||||||
return "orientationLandscape";
|
|
||||||
default:
|
|
||||||
return "orientationSquare";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import {
|
|
||||||
autoContrast,
|
|
||||||
brightnessContrast,
|
|
||||||
changeHue,
|
|
||||||
extractChannel,
|
|
||||||
gammaCorrection,
|
|
||||||
grayscale,
|
|
||||||
invert,
|
|
||||||
posterize,
|
|
||||||
sepia,
|
|
||||||
setOpacity,
|
|
||||||
swapChannels,
|
|
||||||
temperature,
|
|
||||||
tint,
|
|
||||||
thresholdBlackWhite,
|
|
||||||
twoColors,
|
|
||||||
type ChannelSwapPair,
|
|
||||||
type RgbChannel,
|
|
||||||
} from "../../core/color";
|
|
||||||
import { renderSpace, SPACES, type SpaceId } from "../../core/channels";
|
|
||||||
import { quantizeImage, ditherImage, mapToNearest } from "../../core/quantize";
|
|
||||||
import { parseHexList } from "../../core/palette";
|
|
||||||
import { num, str } from "../registry-helpers";
|
|
||||||
|
|
||||||
type SpaceEntry = {
|
|
||||||
id: SpaceId;
|
|
||||||
suffix: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CHANNEL_SPACES: SpaceEntry[] = [
|
|
||||||
{
|
|
||||||
id: "hsl",
|
|
||||||
suffix: "hsl",
|
|
||||||
title: "Split PNG into HSL",
|
|
||||||
description:
|
|
||||||
"Decomposes the image into Hue, Saturation and Lightness components.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "hsv",
|
|
||||||
suffix: "hsv",
|
|
||||||
title: "Split PNG into HSV",
|
|
||||||
description:
|
|
||||||
"Decomposes the image into Hue, Saturation and Value (brightness) components.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "hsi",
|
|
||||||
suffix: "hsi",
|
|
||||||
title: "Split PNG into HSI",
|
|
||||||
description:
|
|
||||||
"Decomposes the image into Hue, Saturation and Intensity components.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cmyk",
|
|
||||||
suffix: "cmyk",
|
|
||||||
title: "Convert PNG to CMYK Colors",
|
|
||||||
description:
|
|
||||||
"Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ycbcr",
|
|
||||||
suffix: "ycbcr",
|
|
||||||
title: "Convert PNG to YCbCr Colors",
|
|
||||||
description:
|
|
||||||
"Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lab",
|
|
||||||
suffix: "lab",
|
|
||||||
title: "Convert PNG to LAB Colors",
|
|
||||||
description:
|
|
||||||
"Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function channelEntries(): ToolEntry[] {
|
|
||||||
return CHANNEL_SPACES.map((space) => {
|
|
||||||
const components = SPACES[space.id].components;
|
|
||||||
return {
|
|
||||||
id: `png-to-${space.suffix}`,
|
|
||||||
title: space.title,
|
|
||||||
description: space.description,
|
|
||||||
category: "color" as const,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "component",
|
|
||||||
label: "Component",
|
|
||||||
type: "select" as const,
|
|
||||||
default: components[0],
|
|
||||||
options: components.map((c) => ({
|
|
||||||
value: c,
|
|
||||||
label: c.toUpperCase(),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "display",
|
|
||||||
label: "Display mode",
|
|
||||||
type: "select" as const,
|
|
||||||
default: "gray",
|
|
||||||
options: [
|
|
||||||
{ value: "gray", label: "Grayscale" },
|
|
||||||
{ value: "color", label: "Space as RGB" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
renderSpace(
|
|
||||||
img,
|
|
||||||
space.id,
|
|
||||||
str(p, "component"),
|
|
||||||
str(p, "display") === "color" ? "color" : "gray",
|
|
||||||
),
|
|
||||||
} satisfies ToolEntry;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function colorEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
...channelEntries(),
|
|
||||||
{
|
|
||||||
id: "grayscale-png",
|
|
||||||
title: "Grayscale PNG",
|
|
||||||
description:
|
|
||||||
"Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.",
|
|
||||||
category: "color",
|
|
||||||
params: [],
|
|
||||||
run: (img) => grayscale(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "invert-colors-png",
|
|
||||||
title: "Invert colors PNG",
|
|
||||||
description:
|
|
||||||
"Inverts each color channel (255 − value). Alpha is unchanged.",
|
|
||||||
category: "color",
|
|
||||||
params: [],
|
|
||||||
run: (img) => invert(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "adjust-brightness-contrast-png",
|
|
||||||
title: "Brightness & contrast PNG",
|
|
||||||
description:
|
|
||||||
"Adjusts brightness and contrast in the range from −100 to +100. Zero means no change.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "brightness",
|
|
||||||
label: "Brightness",
|
|
||||||
type: "slider",
|
|
||||||
min: -100,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "contrast",
|
|
||||||
label: "Contrast",
|
|
||||||
type: "slider",
|
|
||||||
min: -100,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
brightnessContrast(img, num(p, "brightness"), num(p, "contrast")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "change-png-opacity",
|
|
||||||
title: "Change PNG opacity",
|
|
||||||
description:
|
|
||||||
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "percent",
|
|
||||||
label: "Opacity, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => setOpacity(img, num(p, "percent")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sepia-png",
|
|
||||||
title: "Sepia effect",
|
|
||||||
description:
|
|
||||||
"Tints the image into the warm brown tones of classic sepia.",
|
|
||||||
category: "color",
|
|
||||||
params: [],
|
|
||||||
run: (img) => sepia(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "change-png-hue",
|
|
||||||
title: "Change hue PNG",
|
|
||||||
description:
|
|
||||||
"Shifts the hue around the circle. Saturation and lightness are preserved.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "degrees",
|
|
||||||
label: "Hue shift, °",
|
|
||||||
type: "slider",
|
|
||||||
min: -180,
|
|
||||||
max: 180,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => changeHue(img, num(p, "degrees")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "extract-channel-png",
|
|
||||||
title: "Extract channel PNG",
|
|
||||||
description:
|
|
||||||
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "channel",
|
|
||||||
label: "Channel",
|
|
||||||
type: "select",
|
|
||||||
default: "red",
|
|
||||||
options: [
|
|
||||||
{ value: "red", label: "Red" },
|
|
||||||
{ value: "green", label: "Green" },
|
|
||||||
{ value: "blue", label: "Blue" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => extractChannel(img, str(p, "channel") as RgbChannel),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "swap-channels-png",
|
|
||||||
title: "Swap channels PNG",
|
|
||||||
description:
|
|
||||||
"Swaps two color channels — a quick way to get unusual coloring.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "pair",
|
|
||||||
label: "Channel pair",
|
|
||||||
type: "select",
|
|
||||||
default: "r-g",
|
|
||||||
options: [
|
|
||||||
{ value: "r-g", label: "Red ↔ Green" },
|
|
||||||
{ value: "r-b", label: "Red ↔ Blue" },
|
|
||||||
{ value: "g-b", label: "Green ↔ Blue" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => swapChannels(img, str(p, "pair") as ChannelSwapPair),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "black-and-white-png",
|
|
||||||
title: "Black & white threshold PNG",
|
|
||||||
description:
|
|
||||||
"Hard binarization by luminance: every pixel becomes black or white.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "threshold",
|
|
||||||
label: "Brightness threshold, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => thresholdBlackWhite(img, num(p, "threshold")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "posterize-png",
|
|
||||||
title: "Posterize PNG",
|
|
||||||
description:
|
|
||||||
"Reduces the number of levels per channel — a poster effect.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "levels",
|
|
||||||
label: "Levels per channel",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 16,
|
|
||||||
step: 1,
|
|
||||||
default: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => posterize(img, num(p, "levels")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "two-colors-png",
|
|
||||||
title: "Two colors PNG",
|
|
||||||
description:
|
|
||||||
"Recolors the image into two chosen colors by luminance threshold.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "lightColor",
|
|
||||||
label: "Light areas color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "darkColor",
|
|
||||||
label: "Dark areas color",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "threshold",
|
|
||||||
label: "Brightness threshold, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
twoColors(
|
|
||||||
img,
|
|
||||||
str(p, "lightColor"),
|
|
||||||
str(p, "darkColor"),
|
|
||||||
num(p, "threshold"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gamma-png",
|
|
||||||
title: "Gamma correction PNG",
|
|
||||||
description:
|
|
||||||
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "value",
|
|
||||||
label: "Gamma",
|
|
||||||
type: "slider",
|
|
||||||
min: 0.1,
|
|
||||||
max: 3,
|
|
||||||
step: 0.05,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => gammaCorrection(img, num(p, "value")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "auto-contrast-png",
|
|
||||||
title: "Auto contrast PNG",
|
|
||||||
description:
|
|
||||||
"Stretches each channel's range across the full available brightness range.",
|
|
||||||
category: "color",
|
|
||||||
params: [],
|
|
||||||
run: (img) => autoContrast(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "temperature-png",
|
|
||||||
title: "Temperature PNG",
|
|
||||||
description:
|
|
||||||
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "percent",
|
|
||||||
label: "Temperature",
|
|
||||||
type: "slider",
|
|
||||||
min: -100,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => temperature(img, num(p, "percent")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tint-png",
|
|
||||||
title: "Tint PNG",
|
|
||||||
description:
|
|
||||||
"Multiplies color channels by the chosen tint with the given strength.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Tint color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffb060",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "strength",
|
|
||||||
label: "Strength, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 30,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => tint(img, str(p, "color"), num(p, "strength")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "quantize-png",
|
|
||||||
title: "Quantize PNG",
|
|
||||||
description:
|
|
||||||
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "colors",
|
|
||||||
label: "Colors (k)",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 64,
|
|
||||||
step: 1,
|
|
||||||
default: 16,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => quantizeImage(img, num(p, "colors")).image,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "decrease-color-count-png",
|
|
||||||
title: "Decrease Color Count PNG",
|
|
||||||
description:
|
|
||||||
"Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "maxColors",
|
|
||||||
label: "Max colors",
|
|
||||||
type: "select",
|
|
||||||
default: "16",
|
|
||||||
options: [
|
|
||||||
{ value: "2", label: "2" },
|
|
||||||
{ value: "4", label: "4" },
|
|
||||||
{ value: "8", label: "8" },
|
|
||||||
{ value: "16", label: "16" },
|
|
||||||
{ value: "32", label: "32" },
|
|
||||||
{ value: "64", label: "64" },
|
|
||||||
{ value: "128", label: "128" },
|
|
||||||
{ value: "256", label: "256" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => quantizeImage(img, num(p, "maxColors")).image,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "custom-palette-png",
|
|
||||||
title: "Custom Palette PNG",
|
|
||||||
description:
|
|
||||||
"Maps every pixel to the nearest color from your comma-separated hex list.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "colors",
|
|
||||||
label: "Palette (comma-separated hex)",
|
|
||||||
type: "text",
|
|
||||||
default: "#000000,#ffffff",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => mapToNearest(img, parseHexList(str(p, "colors"))),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "dithering-png",
|
|
||||||
title: "Dithering PNG",
|
|
||||||
description:
|
|
||||||
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
|
|
||||||
category: "color",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "colors",
|
|
||||||
label: "Colors (k)",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 16,
|
|
||||||
step: 1,
|
|
||||||
default: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pattern",
|
|
||||||
label: "Pattern",
|
|
||||||
type: "select",
|
|
||||||
default: "floyd-steinberg",
|
|
||||||
options: [
|
|
||||||
{ value: "floyd-steinberg", label: "Floyd–Steinberg" },
|
|
||||||
{ value: "bayer", label: "Bayer 4×4" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
ditherImage(
|
|
||||||
img,
|
|
||||||
num(p, "colors"),
|
|
||||||
str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import { clonePixelImage } from "../../core/types";
|
|
||||||
import { flattenOntoColor } from "../../core/alpha";
|
|
||||||
import { toBase64, toDataUrl, decodeSvgText, encode } from "../../core/io";
|
|
||||||
import { pixelsToHex, hexToPixels } from "../../core/text";
|
|
||||||
import {
|
|
||||||
imageToByteRows,
|
|
||||||
bytesToImage,
|
|
||||||
imageToRgbValues,
|
|
||||||
rgbValuesToImage,
|
|
||||||
} from "../../core/textio";
|
|
||||||
import {
|
|
||||||
COMPRESSION_LEVELS,
|
|
||||||
findMaxColorsWithin,
|
|
||||||
type CompressionLevel,
|
|
||||||
} from "../../core/compress";
|
|
||||||
import { quantizeImage } from "../../core/quantize";
|
|
||||||
import { num, str } from "../registry-helpers";
|
|
||||||
|
|
||||||
function decodeToPng(
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
): ToolEntry {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
category: "convert",
|
|
||||||
params: [],
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
decodeToPng(
|
|
||||||
"jpg-to-png",
|
|
||||||
"Convert JPG to PNG",
|
|
||||||
"Opens a JPEG and saves it as lossless PNG. Transparency, if present, is preserved.",
|
|
||||||
),
|
|
||||||
decodeToPng(
|
|
||||||
"webp-to-png",
|
|
||||||
"Convert WebP to PNG",
|
|
||||||
"Re-encodes a WebP image into universal PNG.",
|
|
||||||
),
|
|
||||||
decodeToPng(
|
|
||||||
"gif-to-png",
|
|
||||||
"Convert GIF to PNG",
|
|
||||||
"Extracts the first frame of a GIF animation and saves it as PNG.",
|
|
||||||
),
|
|
||||||
decodeToPng(
|
|
||||||
"bmp-to-png",
|
|
||||||
"Convert BMP to PNG",
|
|
||||||
"Re-encodes BMP into compact lossless PNG.",
|
|
||||||
),
|
|
||||||
decodeToPng(
|
|
||||||
"ico-to-png",
|
|
||||||
"Convert ICO to PNG",
|
|
||||||
"Turns an .ico icon into a regular PNG of the chosen size.",
|
|
||||||
),
|
|
||||||
{
|
|
||||||
id: "png-to-bmp",
|
|
||||||
title: "Convert PNG to BMP",
|
|
||||||
description:
|
|
||||||
"Saves the image as 24-bit BMP without an alpha channel: transparency is replaced with a black background.",
|
|
||||||
category: "convert",
|
|
||||||
params: [],
|
|
||||||
run: (img) => flattenOntoColor(img, "#000000"),
|
|
||||||
output: { mime: "image/bmp", ext: "bmp" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-to-base64",
|
|
||||||
title: "PNG to Base64",
|
|
||||||
description:
|
|
||||||
"Encodes the image into a base64 string for embedding in code or styles.",
|
|
||||||
category: "convert",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) => toBase64(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "base64-to-png",
|
|
||||||
title: "Base64 to PNG",
|
|
||||||
description:
|
|
||||||
"Decodes a base64 string or data-uri back into an image. Paste the string on the left.",
|
|
||||||
category: "convert",
|
|
||||||
sourceMode: "text",
|
|
||||||
params: [],
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-to-data-uri",
|
|
||||||
title: "PNG to Data URI",
|
|
||||||
description:
|
|
||||||
"Builds a full data-uri (data:image/png;base64,…) for embedding in HTML/CSS.",
|
|
||||||
category: "convert",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) => toDataUrl(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "data-uri-to-png",
|
|
||||||
title: "Data URI to PNG",
|
|
||||||
description: "Decodes data:image/…;base64,… back into an image file.",
|
|
||||||
category: "convert",
|
|
||||||
sourceMode: "text",
|
|
||||||
params: [],
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-to-hex",
|
|
||||||
title: "PNG to HEX pixels",
|
|
||||||
description:
|
|
||||||
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
|
|
||||||
category: "convert",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) => pixelsToHex(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "hex-to-png",
|
|
||||||
title: "HEX pixels to PNG",
|
|
||||||
description:
|
|
||||||
"Assembles an image from rrggbbaa hex values (space separated). Set the width — the height is computed automatically.",
|
|
||||||
category: "convert",
|
|
||||||
sourceMode: "text",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Image width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 10000,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
runFromText: (text, p) =>
|
|
||||||
hexToPixels(text, Math.trunc(Number(p["width"]))),
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-to-bytes",
|
|
||||||
title: "PNG to Bytes",
|
|
||||||
description:
|
|
||||||
"Lists every pixel as four decimal bytes (R G B A), one image row per line.",
|
|
||||||
category: "convert",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) => imageToByteRows(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bytes-to-png",
|
|
||||||
title: "Bytes to PNG",
|
|
||||||
description:
|
|
||||||
"Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.",
|
|
||||||
category: "convert",
|
|
||||||
sourceMode: "text",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Image width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 10000,
|
|
||||||
step: 1,
|
|
||||||
default: 32,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
runFromText: (text, p) =>
|
|
||||||
bytesToImage(
|
|
||||||
text,
|
|
||||||
Math.trunc(num(p as Record<string, unknown>, "width")),
|
|
||||||
),
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "png-to-rgb-values",
|
|
||||||
title: "PNG to RGB Values",
|
|
||||||
description:
|
|
||||||
"Lists every pixel as rgba(r, g, b, a), one image row per line.",
|
|
||||||
category: "convert",
|
|
||||||
params: [],
|
|
||||||
resultType: "text",
|
|
||||||
toText: (img) => imageToRgbValues(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rgb-values-to-png",
|
|
||||||
title: "RGB Values to PNG",
|
|
||||||
description:
|
|
||||||
"Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.",
|
|
||||||
category: "convert",
|
|
||||||
sourceMode: "text",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Image width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 10000,
|
|
||||||
step: 1,
|
|
||||||
default: 32,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
runFromText: (text, p) =>
|
|
||||||
rgbValuesToImage(
|
|
||||||
text,
|
|
||||||
Math.trunc(num(p as Record<string, unknown>, "width")),
|
|
||||||
),
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "convert-png-to-jpg",
|
|
||||||
title: "Convert PNG to JPG",
|
|
||||||
description:
|
|
||||||
"Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
|
|
||||||
category: "convert",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "background",
|
|
||||||
label: "Backdrop color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "quality",
|
|
||||||
label: "JPEG quality",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 90,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
output: {
|
|
||||||
mime: "image/jpeg",
|
|
||||||
ext: "jpg",
|
|
||||||
qualityParamId: "quality",
|
|
||||||
},
|
|
||||||
run: (img, p) => flattenOntoColor(img, str(p, "background")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "convert-png-to-webp",
|
|
||||||
title: "Convert PNG to WebP",
|
|
||||||
description:
|
|
||||||
"Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
|
|
||||||
category: "convert",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "quality",
|
|
||||||
label: "WebP quality",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 90,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
output: {
|
|
||||||
mime: "image/webp",
|
|
||||||
ext: "webp",
|
|
||||||
qualityParamId: "quality",
|
|
||||||
},
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "compress-png",
|
|
||||||
title: "Compress PNG",
|
|
||||||
description:
|
|
||||||
"Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.",
|
|
||||||
category: "convert",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "level",
|
|
||||||
label: "Compression level",
|
|
||||||
type: "select",
|
|
||||||
default: "balanced",
|
|
||||||
options: [
|
|
||||||
{ value: "light", label: "Light (192 colors)" },
|
|
||||||
{ value: "balanced", label: "Balanced (96 colors)" },
|
|
||||||
{ value: "strong", label: "Strong (44 colors)" },
|
|
||||||
{ value: "extreme", label: "Extreme (16 colors)" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => {
|
|
||||||
const level = str(p, "level") as CompressionLevel;
|
|
||||||
const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced;
|
|
||||||
return quantizeImage(img, k).image;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "reduce-to-size-png",
|
|
||||||
title: "Reduce PNG to Size",
|
|
||||||
description:
|
|
||||||
"Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.",
|
|
||||||
category: "convert",
|
|
||||||
domOnly: true,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "targetKB",
|
|
||||||
label: "Target size, KB",
|
|
||||||
type: "slider",
|
|
||||||
min: 5,
|
|
||||||
max: 2000,
|
|
||||||
step: 5,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "maxColors",
|
|
||||||
label: "Max colors to try",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 256,
|
|
||||||
step: 1,
|
|
||||||
default: 256,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: async (img, p) => {
|
|
||||||
const targetBytes = num(p, "targetKB") * 1024;
|
|
||||||
const maxK = num(p, "maxColors");
|
|
||||||
const encodeSize = async (k: number): Promise<number | null> => {
|
|
||||||
const blob = await encode(quantizeImage(img, k).image, "image/png");
|
|
||||||
return blob.size;
|
|
||||||
};
|
|
||||||
const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize);
|
|
||||||
return quantizeImage(img, k).image;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "svg-to-png",
|
|
||||||
title: "SVG to PNG",
|
|
||||||
description:
|
|
||||||
"Decodes SVG markup into a raster image. Paste the SVG code on the left.",
|
|
||||||
category: "convert",
|
|
||||||
sourceMode: "text",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Result width, px",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 10000,
|
|
||||||
step: 1,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
runFromText: (text, p) =>
|
|
||||||
decodeSvgText(text, Math.trunc(num(p, "width"))),
|
|
||||||
run: (img) => clonePixelImage(img),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import { gaussianBlur, sharpen as sharpenImage } from "../../core/convolution";
|
|
||||||
import { vignette } from "../../core/effects";
|
|
||||||
import {
|
|
||||||
pixelate,
|
|
||||||
shuffleBlocks,
|
|
||||||
addNoise,
|
|
||||||
silhouette,
|
|
||||||
} from "../../core/pixel-fx";
|
|
||||||
import { jpegRoundtrip } from "../../core/io";
|
|
||||||
import { num, str } from "../registry-helpers";
|
|
||||||
|
|
||||||
export function filterEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "blur-png",
|
|
||||||
title: "Blur PNG",
|
|
||||||
description:
|
|
||||||
"Gaussian blur: three passes of separable box blur — fast at any radius. Transparent edges do not darken.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "radius",
|
|
||||||
label: "Radius, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 32,
|
|
||||||
step: 1,
|
|
||||||
default: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => gaussianBlur(img, num(p, "radius")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sharpen-png",
|
|
||||||
title: "Sharpen PNG",
|
|
||||||
description:
|
|
||||||
"Emphasizes edges with a sharpening kernel; strength sets the blend with the original. 0% means no change.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "strength",
|
|
||||||
label: "Strength, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => sharpenImage(img, num(p, "strength")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "vignette-png",
|
|
||||||
title: "Vignette PNG",
|
|
||||||
description:
|
|
||||||
"Smoothly darkens the edges of the image, leaving the center untouched.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "strength",
|
|
||||||
label: "Darkening strength, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => vignette(img, num(p, "strength")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pixelate-png",
|
|
||||||
title: "Pixelate PNG",
|
|
||||||
description:
|
|
||||||
"Averages every blockSize×blockSize area into one color — classic mosaic.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "blockSize",
|
|
||||||
label: "Block size, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 64,
|
|
||||||
step: 1,
|
|
||||||
default: 8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => pixelate(img, num(p, "blockSize")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "randomize-pixels-png",
|
|
||||||
title: "Randomize Pixels PNG",
|
|
||||||
description:
|
|
||||||
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "blockSize",
|
|
||||||
label: "Block size, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 64,
|
|
||||||
step: 1,
|
|
||||||
default: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "seed",
|
|
||||||
label: "Seed",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 999999,
|
|
||||||
step: 1,
|
|
||||||
default: 42,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => shuffleBlocks(img, num(p, "blockSize"), num(p, "seed")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "add-noise-png",
|
|
||||||
title: "Add Noise to PNG",
|
|
||||||
description:
|
|
||||||
"Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "amount",
|
|
||||||
label: "Amount, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 25,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mode",
|
|
||||||
label: "Noise type",
|
|
||||||
type: "select",
|
|
||||||
default: "mono",
|
|
||||||
options: [
|
|
||||||
{ value: "mono", label: "Monochrome grain" },
|
|
||||||
{ value: "color", label: "Color noise" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "seed",
|
|
||||||
label: "Seed",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 999999,
|
|
||||||
step: 1,
|
|
||||||
default: 1234,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
addNoise(
|
|
||||||
img,
|
|
||||||
num(p, "amount"),
|
|
||||||
str(p, "mode") === "color" ? "color" : "mono",
|
|
||||||
num(p, "seed"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "silhouette-png",
|
|
||||||
title: "Silhouette PNG",
|
|
||||||
description:
|
|
||||||
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Silhouette color",
|
|
||||||
type: "color",
|
|
||||||
default: "#111318",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "threshold",
|
|
||||||
label: "Visibility threshold, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
silhouette(img, str(p, "color"), num(p, "threshold") * 2.55),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "jpeg-artifacts-png",
|
|
||||||
title: "JPEG artifacts",
|
|
||||||
description:
|
|
||||||
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
|
|
||||||
category: "filters",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "quality",
|
|
||||||
label: "JPEG quality",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => jpegRoundtrip(img, num(p, "quality")),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,950 +0,0 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import { ToolError } from "../../core/errors";
|
|
||||||
import { solidImage, noiseImage, gradientImage } from "../../core/generate";
|
|
||||||
import {
|
|
||||||
colorSpectrum,
|
|
||||||
drawGrid,
|
|
||||||
randomColorBlocks,
|
|
||||||
} from "../../core/gen-tools";
|
|
||||||
import {
|
|
||||||
renderTextToImage,
|
|
||||||
renderEmoji,
|
|
||||||
type TextFont,
|
|
||||||
} from "../../core/domText";
|
|
||||||
import { changeCanvasSize } from "../../core/geometry";
|
|
||||||
import {
|
|
||||||
renderWheel,
|
|
||||||
renderSwatches,
|
|
||||||
renderBlend,
|
|
||||||
complementarySet,
|
|
||||||
triadicSet,
|
|
||||||
tetradicSet,
|
|
||||||
analogousSet,
|
|
||||||
monochromaticSet,
|
|
||||||
shadeSet,
|
|
||||||
mixColors,
|
|
||||||
parseHexList,
|
|
||||||
stepColors,
|
|
||||||
sortPalette,
|
|
||||||
type SortKey,
|
|
||||||
} from "../../core/palette";
|
|
||||||
import { num, str, bool } from "../registry-helpers";
|
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
|
||||||
if (!match) {
|
|
||||||
throw new ToolError("errors.badHex", { value: hex });
|
|
||||||
}
|
|
||||||
const d = match[1];
|
|
||||||
return [
|
|
||||||
parseInt(d.slice(0, 2), 16),
|
|
||||||
parseInt(d.slice(2, 4), 16),
|
|
||||||
parseInt(d.slice(4, 6), 16),
|
|
||||||
alpha,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function paletteParams(baseDefault: string) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "baseColor",
|
|
||||||
label: "Base color",
|
|
||||||
type: "color" as const,
|
|
||||||
default: baseDefault,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "slider" as const,
|
|
||||||
min: 128,
|
|
||||||
max: 1024,
|
|
||||||
step: 16,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "layout",
|
|
||||||
label: "Layout",
|
|
||||||
type: "select" as const,
|
|
||||||
default: "grid",
|
|
||||||
options: [
|
|
||||||
{ value: "grid", label: "Grid" },
|
|
||||||
{ value: "strip", label: "Strip" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "create-empty-png",
|
|
||||||
title: "Create empty PNG",
|
|
||||||
description:
|
|
||||||
"Generates a canvas of the chosen size — transparent or filled with color.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transparent",
|
|
||||||
label: "Transparent",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
solidImage(
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
p["transparent"] === true ? [0, 0, 0, 0] : hexToRgba(str(p, "color")),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "single-color-png",
|
|
||||||
title: "Create solid color PNG",
|
|
||||||
description: "Generates a rectangle of the given size and color.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 256,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 256,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ff0000",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
solidImage(
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
hexToRgba(str(p, "color")),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "random-noise-png",
|
|
||||||
title: "Create random noise PNG",
|
|
||||||
description:
|
|
||||||
"Generates an image with random pixels. The seed fixes the result: one seed — one image.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "seed",
|
|
||||||
label: "Seed",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 999999999,
|
|
||||||
step: 1,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
noiseImage(
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
num(p, "seed"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
sourceMode: "none",
|
|
||||||
domOnly: true,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "text",
|
|
||||||
label: "Text",
|
|
||||||
type: "text",
|
|
||||||
default: "Hello!",
|
|
||||||
placeholder: "Your text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fontSize",
|
|
||||||
label: "Font size, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 8,
|
|
||||||
max: 300,
|
|
||||||
step: 1,
|
|
||||||
default: 96,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "font",
|
|
||||||
label: "Font",
|
|
||||||
type: "select",
|
|
||||||
default: "sans",
|
|
||||||
options: [
|
|
||||||
{ value: "sans", label: "Sans-serif" },
|
|
||||||
{ value: "serif", label: "Serif" },
|
|
||||||
{ value: "mono", label: "Monospace" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bold",
|
|
||||||
label: "Bold",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Text color",
|
|
||||||
type: "color",
|
|
||||||
default: "#111318",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transparentBg",
|
|
||||||
label: "Transparent background",
|
|
||||||
type: "checkbox",
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "backgroundColor",
|
|
||||||
label: "Background color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "padding",
|
|
||||||
label: "Padding, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 200,
|
|
||||||
step: 2,
|
|
||||||
default: 24,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderTextToImage({
|
|
||||||
text: str(p, "text"),
|
|
||||||
fontSize: num(p, "fontSize"),
|
|
||||||
font: str(p, "font") as TextFont,
|
|
||||||
bold: bool(p, "bold"),
|
|
||||||
color: str(p, "color"),
|
|
||||||
backgroundColor: str(p, "backgroundColor"),
|
|
||||||
transparentBg: bool(p, "transparentBg"),
|
|
||||||
padding: num(p, "padding"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "emoji-to-png",
|
|
||||||
title: "Emoji to PNG",
|
|
||||||
description:
|
|
||||||
"Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
domOnly: true,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "emoji",
|
|
||||||
label: "Emoji / symbol",
|
|
||||||
type: "text",
|
|
||||||
default: "\u{1F600}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "size",
|
|
||||||
label: "Size",
|
|
||||||
type: "slider",
|
|
||||||
min: 32,
|
|
||||||
max: 1024,
|
|
||||||
step: 16,
|
|
||||||
default: 256,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) => renderEmoji(str(p, "emoji"), Math.trunc(num(p, "size"))),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "placeholder-png",
|
|
||||||
title: "Create Placeholder PNG",
|
|
||||||
description:
|
|
||||||
"Generates a placeholder rectangle with its dimensions printed in the center.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
domOnly: true,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 400,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "backgroundColor",
|
|
||||||
label: "Background",
|
|
||||||
type: "color",
|
|
||||||
default: "#dfe2e8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Text color",
|
|
||||||
type: "color",
|
|
||||||
default: "#5c6470",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "showText",
|
|
||||||
label: "Print dimensions",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) => {
|
|
||||||
const w = Math.trunc(num(p, "width"));
|
|
||||||
const h = Math.trunc(num(p, "height"));
|
|
||||||
const label = renderTextToImage({
|
|
||||||
text: `${w} × ${h}`,
|
|
||||||
fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)),
|
|
||||||
font: "sans",
|
|
||||||
bold: true,
|
|
||||||
color: str(p, "color"),
|
|
||||||
backgroundColor: str(p, "backgroundColor"),
|
|
||||||
transparentBg: false,
|
|
||||||
padding: 0,
|
|
||||||
});
|
|
||||||
return changeCanvasSize(label, w, h, "center");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "linear-gradient-png",
|
|
||||||
title: "Create gradient PNG",
|
|
||||||
description:
|
|
||||||
"Generates a smooth transition between two colors, horizontally or vertically.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fromColor",
|
|
||||||
label: "Start color",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "toColor",
|
|
||||||
label: "End color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "direction",
|
|
||||||
label: "Direction",
|
|
||||||
type: "select",
|
|
||||||
default: "horizontal",
|
|
||||||
options: [
|
|
||||||
{ value: "horizontal", label: "Horizontal" },
|
|
||||||
{ value: "vertical", label: "Vertical" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
gradientImage(
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
hexToRgba(str(p, "fromColor")),
|
|
||||||
hexToRgba(str(p, "toColor")),
|
|
||||||
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color-spectrum-png",
|
|
||||||
title: "Color Spectrum PNG",
|
|
||||||
description:
|
|
||||||
"Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 1024,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 128,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "direction",
|
|
||||||
label: "Direction",
|
|
||||||
type: "select",
|
|
||||||
default: "horizontal",
|
|
||||||
options: [
|
|
||||||
{ value: "horizontal", label: "Horizontal" },
|
|
||||||
{ value: "vertical", label: "Vertical" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "saturation",
|
|
||||||
label: "Saturation, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lightness",
|
|
||||||
label: "Lightness, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
colorSpectrum(
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
|
|
||||||
num(p, "saturation"),
|
|
||||||
num(p, "lightness"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "random-colors-png",
|
|
||||||
title: "Random Color Blocks PNG",
|
|
||||||
description:
|
|
||||||
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "blockSize",
|
|
||||||
label: "Block size, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 4,
|
|
||||||
max: 256,
|
|
||||||
step: 2,
|
|
||||||
default: 64,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "seed",
|
|
||||||
label: "Seed",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 999999999,
|
|
||||||
step: 1,
|
|
||||||
default: 7,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
randomColorBlocks(
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
num(p, "blockSize"),
|
|
||||||
num(p, "seed"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "draw-grid-png",
|
|
||||||
title: "Draw Grid PNG",
|
|
||||||
description:
|
|
||||||
"Draws a grid with custom columns, rows and line width on a transparent or white background.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cols",
|
|
||||||
label: "Columns",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 64,
|
|
||||||
step: 1,
|
|
||||||
default: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rows",
|
|
||||||
label: "Rows",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 64,
|
|
||||||
step: 1,
|
|
||||||
default: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lineWidth",
|
|
||||||
label: "Line width, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 1,
|
|
||||||
max: 40,
|
|
||||||
step: 1,
|
|
||||||
default: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Line color",
|
|
||||||
type: "color",
|
|
||||||
default: "#111318",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transparentBg",
|
|
||||||
label: "Transparent background",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
drawGrid(
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
num(p, "cols"),
|
|
||||||
num(p, "rows"),
|
|
||||||
num(p, "lineWidth"),
|
|
||||||
str(p, "color"),
|
|
||||||
bool(p, "transparentBg"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color-wheel-png",
|
|
||||||
title: "Color Wheel PNG",
|
|
||||||
description:
|
|
||||||
"Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Size",
|
|
||||||
type: "slider",
|
|
||||||
min: 128,
|
|
||||||
max: 1024,
|
|
||||||
step: 16,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lightness",
|
|
||||||
label: "Lightness, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderWheel(Math.trunc(num(p, "width")), num(p, "lightness")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "complementary-png",
|
|
||||||
title: "Complementary Palette PNG",
|
|
||||||
description:
|
|
||||||
"Two opposite colors on the color wheel — the base and its complement.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: paletteParams("#2563eb"),
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
complementarySet(str(p, "baseColor")),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "triadic-png",
|
|
||||||
title: "Triadic Palette PNG",
|
|
||||||
description: "Three colors evenly spaced 120° apart on the color wheel.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: paletteParams("#ff0000"),
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
triadicSet(str(p, "baseColor")),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tetradic-png",
|
|
||||||
title: "Tetradic Palette PNG",
|
|
||||||
description:
|
|
||||||
"Four colors in two complementary pairs, 90° apart on the wheel.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: paletteParams("#8000ff"),
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
tetradicSet(str(p, "baseColor")),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analogous-png",
|
|
||||||
title: "Analogous Palette PNG",
|
|
||||||
description:
|
|
||||||
"Neighboring hues around the base color — calm, related color scheme.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
...paletteParams("#22c55e"),
|
|
||||||
{
|
|
||||||
id: "spread",
|
|
||||||
label: "Hue spread, °",
|
|
||||||
type: "slider",
|
|
||||||
min: 10,
|
|
||||||
max: 90,
|
|
||||||
step: 5,
|
|
||||||
default: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "count",
|
|
||||||
label: "Colors",
|
|
||||||
type: "slider",
|
|
||||||
min: 3,
|
|
||||||
max: 9,
|
|
||||||
step: 1,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
analogousSet(str(p, "baseColor"), num(p, "spread"), num(p, "count")),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "monochromatic-png",
|
|
||||||
title: "Monochromatic Palette PNG",
|
|
||||||
description:
|
|
||||||
"Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
...paletteParams("#0ea5e9"),
|
|
||||||
{
|
|
||||||
id: "count",
|
|
||||||
label: "Colors",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 9,
|
|
||||||
step: 1,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "range",
|
|
||||||
label: "Lightness range, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 10,
|
|
||||||
max: 90,
|
|
||||||
step: 5,
|
|
||||||
default: 40,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
monochromaticSet(
|
|
||||||
str(p, "baseColor"),
|
|
||||||
num(p, "count"),
|
|
||||||
num(p, "range"),
|
|
||||||
),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "shades-png",
|
|
||||||
title: "Shade Ramp PNG",
|
|
||||||
description: "A ramp of the base color getting darker step by step.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
...paletteParams("#f59e0b"),
|
|
||||||
{
|
|
||||||
id: "count",
|
|
||||||
label: "Colors",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 9,
|
|
||||||
step: 1,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "depth",
|
|
||||||
label: "Darkening depth, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 10,
|
|
||||||
max: 90,
|
|
||||||
step: 5,
|
|
||||||
default: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
shadeSet(str(p, "baseColor"), num(p, "count"), num(p, "depth")),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mix-colors-png",
|
|
||||||
title: "Mix Colors PNG",
|
|
||||||
description:
|
|
||||||
"Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "colors",
|
|
||||||
label: "Colors (comma-separated hex)",
|
|
||||||
type: "text",
|
|
||||||
default: "#ff0000,#00ff00,#0000ff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "slider",
|
|
||||||
min: 128,
|
|
||||||
max: 1024,
|
|
||||||
step: 16,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
[mixColors(parseHexList(str(p, "colors")))],
|
|
||||||
num(p, "width"),
|
|
||||||
"strip",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "blend-two-png",
|
|
||||||
title: "Blend Two Colors PNG",
|
|
||||||
description: "A continuous horizontal gradient between two colors.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "colorA",
|
|
||||||
label: "Color A",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "colorB",
|
|
||||||
label: "Color B",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "slider",
|
|
||||||
min: 128,
|
|
||||||
max: 1024,
|
|
||||||
step: 16,
|
|
||||||
default: 512,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderBlend(str(p, "colorA"), str(p, "colorB"), num(p, "width")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "step-colors-png",
|
|
||||||
title: "Color Steps PNG",
|
|
||||||
description: "A discrete set of evenly spaced steps between two colors.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "colorA",
|
|
||||||
label: "Color A",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "colorB",
|
|
||||||
label: "Color B",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "steps",
|
|
||||||
label: "Steps",
|
|
||||||
type: "slider",
|
|
||||||
min: 2,
|
|
||||||
max: 12,
|
|
||||||
step: 1,
|
|
||||||
default: 6,
|
|
||||||
},
|
|
||||||
...paletteParams("#808080").filter((q) => q.id !== "baseColor"),
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
stepColors(str(p, "colorA"), str(p, "colorB"), num(p, "steps")),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sort-colors-png",
|
|
||||||
title: "Sort Colors PNG",
|
|
||||||
description:
|
|
||||||
"Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.",
|
|
||||||
category: "generate",
|
|
||||||
sourceMode: "none",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "colors",
|
|
||||||
label: "Colors (comma-separated hex)",
|
|
||||||
type: "text",
|
|
||||||
default: "#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "order",
|
|
||||||
label: "Sort by",
|
|
||||||
type: "select",
|
|
||||||
default: "hue",
|
|
||||||
options: [
|
|
||||||
{ value: "hue", label: "Hue" },
|
|
||||||
{ value: "luma", label: "Brightness" },
|
|
||||||
{ value: "sat", label: "Saturation" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
...paletteParams("#ffffff").filter((q) => q.id !== "baseColor"),
|
|
||||||
],
|
|
||||||
generate: (p) =>
|
|
||||||
renderSwatches(
|
|
||||||
sortPalette(
|
|
||||||
parseHexList(str(p, "colors")),
|
|
||||||
str(p, "order") as SortKey,
|
|
||||||
),
|
|
||||||
num(p, "width"),
|
|
||||||
str(p, "layout") as "strip" | "grid",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
import {
|
|
||||||
rotateFreeImage,
|
|
||||||
skewImage,
|
|
||||||
transformImage,
|
|
||||||
zoomImage,
|
|
||||||
} from "../../core/affine";
|
|
||||||
import { ToolError } from "../../core/errors";
|
|
||||||
import {
|
|
||||||
centerByAlpha,
|
|
||||||
changeCanvasSize,
|
|
||||||
crop,
|
|
||||||
cropToRatio,
|
|
||||||
expandCanvas,
|
|
||||||
flip,
|
|
||||||
forceOrientation,
|
|
||||||
padToRatio,
|
|
||||||
resize,
|
|
||||||
rotate90,
|
|
||||||
symmetricCopy,
|
|
||||||
tile,
|
|
||||||
trimToContent,
|
|
||||||
type Anchor9,
|
|
||||||
} from "../../core/geometry";
|
|
||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import { num, str } from "../registry-helpers";
|
|
||||||
|
|
||||||
export function geometryEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "resize-png",
|
|
||||||
title: "Resize PNG",
|
|
||||||
description:
|
|
||||||
"Scales the image with bilinear interpolation. With aspect kept, one side defines the scale; if both are set, the image fits inside them.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width (0 = auto)",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height (0 = auto)",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "keepAspect",
|
|
||||||
label: "Keep aspect ratio",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => {
|
|
||||||
const keepAspect = p["keepAspect"] === true;
|
|
||||||
let w = Math.trunc(num(p, "width"));
|
|
||||||
let h = Math.trunc(num(p, "height"));
|
|
||||||
if (keepAspect) {
|
|
||||||
if (w > 0 && h > 0) {
|
|
||||||
const scale = Math.min(w / img.width, h / img.height);
|
|
||||||
w = Math.max(1, Math.round(img.width * scale));
|
|
||||||
h = Math.max(1, Math.round(img.height * scale));
|
|
||||||
} else if (w > 0) {
|
|
||||||
h = Math.max(1, Math.round((img.height / img.width) * w));
|
|
||||||
} else if (h > 0) {
|
|
||||||
w = Math.max(1, Math.round((img.width / img.height) * h));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (w <= 0 || h <= 0) {
|
|
||||||
throw new ToolError("errors.resizeSize");
|
|
||||||
}
|
|
||||||
return resize(img, w, h);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "crop-png",
|
|
||||||
title: "Crop PNG",
|
|
||||||
description:
|
|
||||||
"Cuts out a rectangular area. Coordinates and sizes may go beyond the image — the area is clipped to the intersection.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "x",
|
|
||||||
label: "X (from left)",
|
|
||||||
type: "number",
|
|
||||||
min: -0,
|
|
||||||
max: 2000,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "y",
|
|
||||||
label: "Y (from top)",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 1200,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Area width",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 2000,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Area height",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
max: 1200,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => {
|
|
||||||
const w = Math.trunc(num(p, "width"));
|
|
||||||
const h = Math.trunc(num(p, "height"));
|
|
||||||
if (w <= 0 || h <= 0) {
|
|
||||||
throw new ToolError("errors.cropSize");
|
|
||||||
}
|
|
||||||
return crop(
|
|
||||||
img,
|
|
||||||
Math.trunc(num(p, "x")),
|
|
||||||
Math.trunc(num(p, "y")),
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rotate-png",
|
|
||||||
title: "Rotate PNG",
|
|
||||||
description:
|
|
||||||
"Rotates by 90°, 180° or 270° clockwise without quality loss.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "angle",
|
|
||||||
label: "Rotation angle",
|
|
||||||
type: "select",
|
|
||||||
default: "90",
|
|
||||||
options: [
|
|
||||||
{ value: "90", label: "90° clockwise" },
|
|
||||||
{ value: "180", label: "180°" },
|
|
||||||
{ value: "270", label: "270° clockwise" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => rotate90(img, Number(str(p, "angle")) / 90),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "flip-png",
|
|
||||||
title: "Flip PNG",
|
|
||||||
description: "Mirrors horizontally or vertically without quality loss.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "axis",
|
|
||||||
label: "Flip axis",
|
|
||||||
type: "select",
|
|
||||||
default: "horizontal",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "horizontal",
|
|
||||||
label: "Horizontal (left to right)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "vertical",
|
|
||||||
label: "Vertical (top to bottom)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
flip(img, str(p, "axis") === "vertical" ? "vertical" : "horizontal"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "add-padding-png",
|
|
||||||
title: "Add padding to PNG",
|
|
||||||
description:
|
|
||||||
"Expands the canvas on all sides by the chosen number of pixels.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "padding",
|
|
||||||
label: "Padding, px",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 2000,
|
|
||||||
step: 1,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transparent",
|
|
||||||
label: "Transparent padding",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Padding color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
expandCanvas(
|
|
||||||
img,
|
|
||||||
num(p, "padding"),
|
|
||||||
num(p, "padding"),
|
|
||||||
num(p, "padding"),
|
|
||||||
num(p, "padding"),
|
|
||||||
p["transparent"] === true ? undefined : str(p, "color"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "add-border-png",
|
|
||||||
title: "Add border to PNG",
|
|
||||||
description:
|
|
||||||
"Draws a colored frame of the chosen thickness around the image.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "thickness",
|
|
||||||
label: "Border thickness, px",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 500,
|
|
||||||
step: 1,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Border color",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
expandCanvas(
|
|
||||||
img,
|
|
||||||
num(p, "thickness"),
|
|
||||||
num(p, "thickness"),
|
|
||||||
num(p, "thickness"),
|
|
||||||
num(p, "thickness"),
|
|
||||||
str(p, "color"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fit-on-background-png",
|
|
||||||
title: "Fit PNG onto background",
|
|
||||||
description:
|
|
||||||
"Places the image centered on a canvas of the given size with a transparent or colored background.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Canvas width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Canvas height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transparent",
|
|
||||||
label: "Transparent background",
|
|
||||||
type: "checkbox",
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Background color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => {
|
|
||||||
const width = Math.trunc(num(p, "width"));
|
|
||||||
const height = Math.trunc(num(p, "height"));
|
|
||||||
if (width <= 0 || height <= 0) {
|
|
||||||
throw new ToolError("errors.sizePositive");
|
|
||||||
}
|
|
||||||
const left = Math.max(0, Math.floor((width - img.width) / 2));
|
|
||||||
const top = Math.max(0, Math.floor((height - img.height) / 2));
|
|
||||||
return expandCanvas(
|
|
||||||
img,
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
Math.max(0, width - img.width - left),
|
|
||||||
Math.max(0, height - img.height - top),
|
|
||||||
p["transparent"] === true ? undefined : str(p, "color"),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tile-png",
|
|
||||||
title: "Tile PNG",
|
|
||||||
description:
|
|
||||||
"Repeats the image in a grid of the chosen columns and rows.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "columns",
|
|
||||||
label: "Columns",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rows",
|
|
||||||
label: "Rows",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 50,
|
|
||||||
step: 1,
|
|
||||||
default: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => tile(img, num(p, "columns"), num(p, "rows")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "center-by-alpha-png",
|
|
||||||
title: "Center PNG by content",
|
|
||||||
description:
|
|
||||||
"Finds the opaque part of the image and centers it on the original canvas.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [],
|
|
||||||
run: (img) => centerByAlpha(img),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "skew-png",
|
|
||||||
title: "Skew PNG",
|
|
||||||
description:
|
|
||||||
"Shifts content horizontally and vertically — a perspective effect.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "degX",
|
|
||||||
label: "Skew X, °",
|
|
||||||
type: "slider",
|
|
||||||
min: -80,
|
|
||||||
max: 80,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "degY",
|
|
||||||
label: "Skew Y, °",
|
|
||||||
type: "slider",
|
|
||||||
min: -80,
|
|
||||||
max: 80,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => skewImage(img, num(p, "degX"), num(p, "degY")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rotate-free-png",
|
|
||||||
title: "Rotate by custom angle",
|
|
||||||
description:
|
|
||||||
"Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "angle",
|
|
||||||
label: "Angle, °",
|
|
||||||
type: "slider",
|
|
||||||
min: -180,
|
|
||||||
max: 180,
|
|
||||||
step: 1,
|
|
||||||
default: 15,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => rotateFreeImage(img, num(p, "angle")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "zoom-png",
|
|
||||||
title: "Zoom PNG",
|
|
||||||
description:
|
|
||||||
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "scale",
|
|
||||||
label: "Scale, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 100,
|
|
||||||
max: 500,
|
|
||||||
step: 10,
|
|
||||||
default: 200,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => zoomImage(img, num(p, "scale")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trim-empty-space-png",
|
|
||||||
title: "Trim Empty Space PNG",
|
|
||||||
description:
|
|
||||||
"Crops transparent borders around the content. Pixels with alpha above the threshold count as content.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "threshold",
|
|
||||||
label: "Alpha threshold",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 254,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => trimToContent(img, num(p, "threshold")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "change-canvas-size-png",
|
|
||||||
title: "Change Canvas Size PNG",
|
|
||||||
description:
|
|
||||||
"Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "width",
|
|
||||||
label: "Width",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 800,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "height",
|
|
||||||
label: "Height",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
max: 20000,
|
|
||||||
step: 1,
|
|
||||||
default: 600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "anchor",
|
|
||||||
label: "Anchor",
|
|
||||||
type: "select",
|
|
||||||
default: "center",
|
|
||||||
options: [
|
|
||||||
{ value: "top-left", label: "Top left" },
|
|
||||||
{ value: "top-center", label: "Top center" },
|
|
||||||
{ value: "top-right", label: "Top right" },
|
|
||||||
{ value: "middle-left", label: "Middle left" },
|
|
||||||
{ value: "center", label: "Center" },
|
|
||||||
{ value: "middle-right", label: "Middle right" },
|
|
||||||
{ value: "bottom-left", label: "Bottom left" },
|
|
||||||
{ value: "bottom-center", label: "Bottom center" },
|
|
||||||
{ value: "bottom-right", label: "Bottom right" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
changeCanvasSize(
|
|
||||||
img,
|
|
||||||
Math.trunc(num(p, "width")),
|
|
||||||
Math.trunc(num(p, "height")),
|
|
||||||
str(p, "anchor") as Anchor9,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "change-aspect-ratio-png",
|
|
||||||
title: "Change Aspect Ratio PNG",
|
|
||||||
description:
|
|
||||||
"Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "ratio",
|
|
||||||
label: "Target ratio",
|
|
||||||
type: "select",
|
|
||||||
default: "1:1",
|
|
||||||
options: [
|
|
||||||
{ value: "1:1", label: "1:1" },
|
|
||||||
{ value: "4:3", label: "4:3" },
|
|
||||||
{ value: "3:4", label: "3:4" },
|
|
||||||
{ value: "3:2", label: "3:2" },
|
|
||||||
{ value: "2:3", label: "2:3" },
|
|
||||||
{ value: "16:9", label: "16:9" },
|
|
||||||
{ value: "9:16", label: "9:16" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mode",
|
|
||||||
label: "Mode",
|
|
||||||
type: "select",
|
|
||||||
default: "crop",
|
|
||||||
options: [
|
|
||||||
{ value: "crop", label: "Crop to fill" },
|
|
||||||
{ value: "pad", label: "Pad to fit" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) => {
|
|
||||||
const [rw, rh] = str(p, "ratio").split(":").map(Number);
|
|
||||||
const ratio = rw / rh;
|
|
||||||
return str(p, "mode") === "pad"
|
|
||||||
? padToRatio(img, ratio)
|
|
||||||
: cropToRatio(img, ratio);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "swap-orientation-png",
|
|
||||||
title: "Swap Orientation PNG",
|
|
||||||
description:
|
|
||||||
"Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "target",
|
|
||||||
label: "Target orientation",
|
|
||||||
type: "select",
|
|
||||||
default: "portrait",
|
|
||||||
options: [
|
|
||||||
{ value: "portrait", label: "Portrait" },
|
|
||||||
{ value: "landscape", label: "Landscape" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
forceOrientation(
|
|
||||||
img,
|
|
||||||
str(p, "target") === "landscape" ? "landscape" : "portrait",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "symmetric-copy-png",
|
|
||||||
title: "Symmetric Copy PNG",
|
|
||||||
description:
|
|
||||||
"Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "axis",
|
|
||||||
label: "Mirror line",
|
|
||||||
type: "select",
|
|
||||||
default: "vertical",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "vertical",
|
|
||||||
label: "Vertical (double width)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "horizontal",
|
|
||||||
label: "Horizontal (double height)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "keepSide",
|
|
||||||
label: "Keep side",
|
|
||||||
type: "select",
|
|
||||||
default: "left",
|
|
||||||
options: [
|
|
||||||
{ value: "left", label: "Left" },
|
|
||||||
{ value: "right", label: "Right" },
|
|
||||||
{ value: "top", label: "Top" },
|
|
||||||
{ value: "bottom", label: "Bottom" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
symmetricCopy(
|
|
||||||
img,
|
|
||||||
str(p, "axis") === "horizontal" ? "horizontal" : "vertical",
|
|
||||||
str(p, "keepSide") as "left" | "right" | "top" | "bottom",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "shift-png",
|
|
||||||
title: "Shift PNG",
|
|
||||||
description: "Moves content by the given X and Y offset.",
|
|
||||||
category: "geometry",
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "offsetX",
|
|
||||||
label: "Offset X, px",
|
|
||||||
type: "number",
|
|
||||||
min: -5000,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offsetY",
|
|
||||||
label: "Offset Y, px",
|
|
||||||
type: "number",
|
|
||||||
min: -5000,
|
|
||||||
max: 5000,
|
|
||||||
step: 1,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Background color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
transformImage(
|
|
||||||
img,
|
|
||||||
[
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
-Math.trunc(num(p, "offsetX")),
|
|
||||||
-Math.trunc(num(p, "offsetY")),
|
|
||||||
],
|
|
||||||
img.width,
|
|
||||||
img.height,
|
|
||||||
str(p, "color"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import type { ToolEntry } from "../registry";
|
|
||||||
import { drawTextBlock, drawTextTile, type TextFont } from "../../core/domText";
|
|
||||||
import { formatStamp } from "../../core/datefmt";
|
|
||||||
import type { Position9 } from "../../core/textdraw";
|
|
||||||
import { num, str, bool } from "../registry-helpers";
|
|
||||||
|
|
||||||
export function textEntries(): ToolEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "text",
|
|
||||||
label: "Text",
|
|
||||||
type: "text",
|
|
||||||
default: "Hello!",
|
|
||||||
placeholder: "Your text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fontSize",
|
|
||||||
label: "Font size, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 8,
|
|
||||||
max: 200,
|
|
||||||
step: 1,
|
|
||||||
default: 48,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Text color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "font",
|
|
||||||
label: "Font",
|
|
||||||
type: "select",
|
|
||||||
default: "sans",
|
|
||||||
options: [
|
|
||||||
{ value: "sans", label: "Sans-serif" },
|
|
||||||
{ value: "serif", label: "Serif" },
|
|
||||||
{ value: "mono", label: "Monospace" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: "bold", label: "Bold", type: "checkbox", default: true },
|
|
||||||
{
|
|
||||||
id: "position",
|
|
||||||
label: "Position",
|
|
||||||
type: "select",
|
|
||||||
default: "bottom-right",
|
|
||||||
options: [
|
|
||||||
{ value: "top-left", label: "Top left" },
|
|
||||||
{ value: "top-center", label: "Top center" },
|
|
||||||
{ value: "top-right", label: "Top right" },
|
|
||||||
{ value: "middle-left", label: "Middle left" },
|
|
||||||
{ value: "center", label: "Center" },
|
|
||||||
{ value: "middle-right", label: "Middle right" },
|
|
||||||
{ value: "bottom-left", label: "Bottom left" },
|
|
||||||
{ value: "bottom-center", label: "Bottom center" },
|
|
||||||
{ value: "bottom-right", label: "Bottom right" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "margin",
|
|
||||||
label: "Margin, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 200,
|
|
||||||
step: 1,
|
|
||||||
default: 24,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plate",
|
|
||||||
label: "Backing plate",
|
|
||||||
type: "checkbox",
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plateColor",
|
|
||||||
label: "Plate color",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plateOpacity",
|
|
||||||
label: "Plate opacity, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
default: 60,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
drawTextBlock(img, {
|
|
||||||
text: str(p, "text"),
|
|
||||||
fontSize: num(p, "fontSize"),
|
|
||||||
font: str(p, "font") as TextFont,
|
|
||||||
bold: bool(p, "bold"),
|
|
||||||
color: str(p, "color"),
|
|
||||||
opacityPercent: 100,
|
|
||||||
position: str(p, "position") as Position9,
|
|
||||||
margin: num(p, "margin"),
|
|
||||||
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
|
|
||||||
plateOpacityPercent: num(p, "plateOpacity"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "format",
|
|
||||||
label: "Format",
|
|
||||||
type: "text",
|
|
||||||
default: "YYYY-MM-DD",
|
|
||||||
placeholder: "YYYY-MM-DD hh:mm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fontSize",
|
|
||||||
label: "Font size, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 8,
|
|
||||||
max: 200,
|
|
||||||
step: 1,
|
|
||||||
default: 32,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Text color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "font",
|
|
||||||
label: "Font",
|
|
||||||
type: "select",
|
|
||||||
default: "mono",
|
|
||||||
options: [
|
|
||||||
{ value: "sans", label: "Sans-serif" },
|
|
||||||
{ value: "serif", label: "Serif" },
|
|
||||||
{ value: "mono", label: "Monospace" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: "bold", label: "Bold", type: "checkbox", default: false },
|
|
||||||
{
|
|
||||||
id: "position",
|
|
||||||
label: "Position",
|
|
||||||
type: "select",
|
|
||||||
default: "bottom-right",
|
|
||||||
options: [
|
|
||||||
{ value: "top-left", label: "Top left" },
|
|
||||||
{ value: "top-center", label: "Top center" },
|
|
||||||
{ value: "top-right", label: "Top right" },
|
|
||||||
{ value: "middle-left", label: "Middle left" },
|
|
||||||
{ value: "center", label: "Center" },
|
|
||||||
{ value: "middle-right", label: "Middle right" },
|
|
||||||
{ value: "bottom-left", label: "Bottom left" },
|
|
||||||
{ value: "bottom-center", label: "Bottom center" },
|
|
||||||
{ value: "bottom-right", label: "Bottom right" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "margin",
|
|
||||||
label: "Margin, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 200,
|
|
||||||
step: 1,
|
|
||||||
default: 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plate",
|
|
||||||
label: "Backing plate",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plateColor",
|
|
||||||
label: "Plate color",
|
|
||||||
type: "color",
|
|
||||||
default: "#000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plateOpacity",
|
|
||||||
label: "Plate opacity, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
default: 55,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
drawTextBlock(img, {
|
|
||||||
text: formatStamp(new Date(), str(p, "format")),
|
|
||||||
fontSize: num(p, "fontSize"),
|
|
||||||
font: str(p, "font") as TextFont,
|
|
||||||
bold: bool(p, "bold"),
|
|
||||||
color: str(p, "color"),
|
|
||||||
opacityPercent: 100,
|
|
||||||
position: str(p, "position") as Position9,
|
|
||||||
margin: num(p, "margin"),
|
|
||||||
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
|
|
||||||
plateOpacityPercent: num(p, "plateOpacity"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "watermark-tile-png",
|
|
||||||
title: "Watermark Tile PNG",
|
|
||||||
description:
|
|
||||||
"Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.",
|
|
||||||
category: "text",
|
|
||||||
domOnly: true,
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
id: "text",
|
|
||||||
label: "Text",
|
|
||||||
type: "text",
|
|
||||||
default: "DRAFT",
|
|
||||||
placeholder: "Watermark text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fontSize",
|
|
||||||
label: "Font size, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 12,
|
|
||||||
max: 160,
|
|
||||||
step: 1,
|
|
||||||
default: 56,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
label: "Text color",
|
|
||||||
type: "color",
|
|
||||||
default: "#ffffff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "opacity",
|
|
||||||
label: "Opacity, %",
|
|
||||||
type: "slider",
|
|
||||||
min: 5,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
default: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "angle",
|
|
||||||
label: "Angle, °",
|
|
||||||
type: "slider",
|
|
||||||
min: -90,
|
|
||||||
max: 90,
|
|
||||||
step: 1,
|
|
||||||
default: -30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stepX",
|
|
||||||
label: "Step X, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 40,
|
|
||||||
max: 600,
|
|
||||||
step: 10,
|
|
||||||
default: 220,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "stepY",
|
|
||||||
label: "Step Y, px",
|
|
||||||
type: "slider",
|
|
||||||
min: 40,
|
|
||||||
max: 600,
|
|
||||||
step: 10,
|
|
||||||
default: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "font",
|
|
||||||
label: "Font",
|
|
||||||
type: "select",
|
|
||||||
default: "sans",
|
|
||||||
options: [
|
|
||||||
{ value: "sans", label: "Sans-serif" },
|
|
||||||
{ value: "serif", label: "Serif" },
|
|
||||||
{ value: "mono", label: "Monospace" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bold",
|
|
||||||
label: "Bold",
|
|
||||||
type: "checkbox",
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
run: (img, p) =>
|
|
||||||
drawTextTile(img, {
|
|
||||||
text: str(p, "text"),
|
|
||||||
fontSize: num(p, "fontSize"),
|
|
||||||
font: str(p, "font") as TextFont,
|
|
||||||
bold: bool(p, "bold"),
|
|
||||||
color: str(p, "color"),
|
|
||||||
opacityPercent: num(p, "opacity"),
|
|
||||||
stepX: num(p, "stepX"),
|
|
||||||
stepY: num(p, "stepY"),
|
|
||||||
angleDeg: num(p, "angle"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export function createAutoRunner(delayMs = 300) {
|
|
||||||
let token = 0;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
next(): number {
|
|
||||||
cancelTimer();
|
|
||||||
return ++token;
|
|
||||||
},
|
|
||||||
isCurrent(t: number): boolean {
|
|
||||||
return t === token;
|
|
||||||
},
|
|
||||||
schedule(run: () => void): () => void {
|
|
||||||
cancelTimer();
|
|
||||||
timer = setTimeout(run, delayMs);
|
|
||||||
return cancelTimer;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function cancelTimer() {
|
|
||||||
if (timer !== undefined) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { executeStep } from "./executor";
|
|
||||||
import { makeImage } from "../../core/test-helpers";
|
|
||||||
import type { PixelImage } from "../../core/types";
|
|
||||||
|
|
||||||
function cloneOf(img: PixelImage): PixelImage {
|
|
||||||
return {
|
|
||||||
width: img.width,
|
|
||||||
height: img.height,
|
|
||||||
data: new Uint8ClampedArray(img.data),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("executeStep: прямой путь (среда без Worker)", () => {
|
|
||||||
it("возвращает результат инструмента", async () => {
|
|
||||||
const img = makeImage(1, 1, [[1, 2, 3, 4]]);
|
|
||||||
const out = await executeStep(
|
|
||||||
{ id: "stub", run: (i) => cloneOf(i) },
|
|
||||||
img,
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
expect(out).toEqual(img);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("пробрасывает ошибку инструмента как есть", async () => {
|
|
||||||
const boom = () => {
|
|
||||||
throw new Error("бум");
|
|
||||||
};
|
|
||||||
await expect(
|
|
||||||
executeStep(
|
|
||||||
{ id: "stub", run: boom },
|
|
||||||
makeImage(1, 1, [[0, 0, 0, 255]]),
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
).rejects.toThrow("бум");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("инструмент без run даёт понятную ошибку", async () => {
|
|
||||||
await expect(
|
|
||||||
executeStep({ id: "stub" }, makeImage(1, 1, [[0, 0, 0, 255]]), {}),
|
|
||||||
).rejects.toThrow("errors.noImageRun");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import type { PixelImage } from "../../core/types";
|
|
||||||
import { ToolError } from "../../core/errors";
|
|
||||||
|
|
||||||
type MaybeRunnable = {
|
|
||||||
id: string;
|
|
||||||
domOnly?: boolean;
|
|
||||||
run?: (
|
|
||||||
img: PixelImage,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
) => Promise<PixelImage> | PixelImage;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function executeStep(
|
|
||||||
tool: MaybeRunnable,
|
|
||||||
img: PixelImage,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
): Promise<PixelImage> {
|
|
||||||
if (!tool.run) {
|
|
||||||
throw new Error("errors.noImageRun");
|
|
||||||
}
|
|
||||||
if (tool.domOnly) {
|
|
||||||
return await runDirect(tool, img, params);
|
|
||||||
}
|
|
||||||
if (typeof Worker === "undefined") {
|
|
||||||
return await runDirect(tool, img, params);
|
|
||||||
}
|
|
||||||
const worker = ensureWorker();
|
|
||||||
if (worker === null) {
|
|
||||||
return await runDirect(tool, img, params);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await runInWorker(worker, tool.id, img, params);
|
|
||||||
} catch (workerError) {
|
|
||||||
disableWorker();
|
|
||||||
void workerError;
|
|
||||||
return await runDirect(tool, img, params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDirect(
|
|
||||||
tool: MaybeRunnable,
|
|
||||||
img: PixelImage,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
): Promise<PixelImage> {
|
|
||||||
if (!tool.run) {
|
|
||||||
throw new Error("errors.noImageRun");
|
|
||||||
}
|
|
||||||
return await tool.run(img, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
let worker: Worker | null = null;
|
|
||||||
let workerTried = false;
|
|
||||||
let nextRequestId = 1;
|
|
||||||
const pending = new Map<
|
|
||||||
number,
|
|
||||||
{ resolve: (img: PixelImage) => void; reject: (e: Error) => void }
|
|
||||||
>();
|
|
||||||
|
|
||||||
function ensureWorker(): Worker | null {
|
|
||||||
if (workerTried) return worker;
|
|
||||||
workerTried = true;
|
|
||||||
try {
|
|
||||||
const candidate = new Worker(
|
|
||||||
new URL("./executor.worker.ts", import.meta.url),
|
|
||||||
{
|
|
||||||
type: "module",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
candidate.onmessage = (event: MessageEvent) => {
|
|
||||||
const payload = event.data as {
|
|
||||||
id: number;
|
|
||||||
ok: boolean;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
data?: Uint8ClampedArray;
|
|
||||||
error?: string;
|
|
||||||
errorKey?: string;
|
|
||||||
errorVars?: Record<string, string | number>;
|
|
||||||
};
|
|
||||||
const entry = pending.get(payload.id);
|
|
||||||
if (!entry) return;
|
|
||||||
pending.delete(payload.id);
|
|
||||||
if (payload.ok && payload.data && payload.width && payload.height) {
|
|
||||||
entry.resolve({
|
|
||||||
width: payload.width,
|
|
||||||
height: payload.height,
|
|
||||||
data: new Uint8ClampedArray(payload.data),
|
|
||||||
});
|
|
||||||
} else if (payload.errorKey) {
|
|
||||||
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
|
||||||
} else {
|
|
||||||
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
candidate.onerror = () => {
|
|
||||||
worker = null;
|
|
||||||
for (const entry of pending.values()) {
|
|
||||||
entry.reject(new Error("errors.workerUnavailable"));
|
|
||||||
}
|
|
||||||
pending.clear();
|
|
||||||
};
|
|
||||||
worker = candidate;
|
|
||||||
} catch {
|
|
||||||
worker = null;
|
|
||||||
}
|
|
||||||
return worker;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableWorker(): void {
|
|
||||||
if (worker) {
|
|
||||||
worker.terminate();
|
|
||||||
}
|
|
||||||
worker = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runInWorker(
|
|
||||||
workerInstance: Worker,
|
|
||||||
toolId: string,
|
|
||||||
img: PixelImage,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
): Promise<PixelImage> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const id = nextRequestId++;
|
|
||||||
pending.set(id, { resolve, reject });
|
|
||||||
workerInstance.postMessage({
|
|
||||||
id,
|
|
||||||
toolId,
|
|
||||||
image: {
|
|
||||||
width: img.width,
|
|
||||||
height: img.height,
|
|
||||||
data: new Uint8ClampedArray(img.data),
|
|
||||||
},
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/// <reference lib="webworker" />
|
|
||||||
import type { PixelImage } from "../../core/types";
|
|
||||||
import { ToolError } from "../../core/errors";
|
|
||||||
import { getTool, sanitizeParams } from "../registry";
|
|
||||||
|
|
||||||
type WorkerRequest = {
|
|
||||||
id: number;
|
|
||||||
toolId: string;
|
|
||||||
image: { width: number; height: number; data: Uint8ClampedArray };
|
|
||||||
params: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
||||||
void handle(event.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handle(request: WorkerRequest): Promise<void> {
|
|
||||||
try {
|
|
||||||
const tool = getTool(request.toolId);
|
|
||||||
if (!tool?.run) {
|
|
||||||
throw new Error("errors.noImageRun");
|
|
||||||
}
|
|
||||||
const image: PixelImage = {
|
|
||||||
width: request.image.width,
|
|
||||||
height: request.image.height,
|
|
||||||
data: new Uint8ClampedArray(request.image.data),
|
|
||||||
};
|
|
||||||
const output = await tool.run(image, sanitizeParams(tool, request.params));
|
|
||||||
const payload = {
|
|
||||||
id: request.id,
|
|
||||||
ok: true,
|
|
||||||
width: output.width,
|
|
||||||
height: output.height,
|
|
||||||
data: output.data,
|
|
||||||
};
|
|
||||||
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
|
|
||||||
} catch (e) {
|
|
||||||
const toolError = e instanceof ToolError ? e : undefined;
|
|
||||||
(self as unknown as Worker).postMessage({
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
errorKey: toolError?.key,
|
|
||||||
errorVars: toolError?.vars,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Картинка-знак для watermark-image-png. Живёт только в состоянии страницы:
|
|
||||||
* сознательно не сохраняется в pipeline/localStorage — после восстановления
|
|
||||||
* цепочки знак нужно выбрать заново.
|
|
||||||
*/
|
|
||||||
let overlay = $state<PixelImage | null>(null);
|
|
||||||
|
|
||||||
export function getOverlay(): PixelImage | null {
|
|
||||||
return overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setOverlay(img: PixelImage): void {
|
|
||||||
overlay = img;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearOverlay(): void {
|
|
||||||
overlay = null;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { createStep, parseDocument } from "./pipeline";
|
|
||||||
|
|
||||||
describe("createStep", () => {
|
|
||||||
it("создаёт шаг с дефолтными значениями инструмента", () => {
|
|
||||||
const step = createStep("grayscale-png");
|
|
||||||
expect(step.toolId).toBe("grayscale-png");
|
|
||||||
expect(step.id.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("бросает ошибку для неизвестного инструмента", () => {
|
|
||||||
expect(() => createStep("no-such-tool")).toThrow(/errors\.toolNotFound/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseDocument", () => {
|
|
||||||
const valid = JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
steps: [
|
|
||||||
{ id: "a", toolId: "grayscale-png", values: {} },
|
|
||||||
{
|
|
||||||
id: "b",
|
|
||||||
toolId: "adjust-brightness-contrast-png",
|
|
||||||
values: { brightness: 5000, contrast: "мусор" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
it("разбирает корректный документ и санитизирует значения", () => {
|
|
||||||
const steps = parseDocument(valid);
|
|
||||||
expect(steps).toHaveLength(2);
|
|
||||||
expect(steps[0].id).toBe("a");
|
|
||||||
expect(steps[1].values).toEqual({ brightness: 100, contrast: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"не json",
|
|
||||||
"{}",
|
|
||||||
JSON.stringify({ version: 99, steps: [] }),
|
|
||||||
JSON.stringify({ version: 1 }),
|
|
||||||
])("отклоняет мусор: %s", (raw) => {
|
|
||||||
expect(() => parseDocument(raw as string)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("пропускает шаги с неизвестным инструментом", () => {
|
|
||||||
const raw = JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
steps: [
|
|
||||||
{ id: "x", toolId: "nope", values: {} },
|
|
||||||
{ id: "y", toolId: "flip-png", values: { axis: "vertical" } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const steps = parseDocument(raw);
|
|
||||||
expect(steps.map((s) => s.id)).toEqual(["y"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { getTool, sanitizeParams } from "../registry";
|
|
||||||
import { ToolError } from "../../core/errors";
|
|
||||||
|
|
||||||
export type PipelineStep = {
|
|
||||||
id: string;
|
|
||||||
toolId: string;
|
|
||||||
values: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PipelineDocument = { version: number; steps: PipelineStep[] };
|
|
||||||
|
|
||||||
export const PIPELINE_VERSION = 1;
|
|
||||||
const STORAGE_KEY = "workspace-pipeline";
|
|
||||||
|
|
||||||
export function createStep(toolId: string): PipelineStep {
|
|
||||||
const tool = getTool(toolId);
|
|
||||||
if (!tool) {
|
|
||||||
throw new ToolError("errors.toolNotFound", { id: toolId });
|
|
||||||
}
|
|
||||||
return { id: newStepId(), toolId, values: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function newStepId(): string {
|
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseDocument(raw: string): PipelineStep[] {
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
throw new ToolError("errors.badJson");
|
|
||||||
}
|
|
||||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
||||||
throw new ToolError("errors.badPipelineShape");
|
|
||||||
}
|
|
||||||
const doc = parsed as { version?: unknown; steps?: unknown };
|
|
||||||
if (doc.version !== PIPELINE_VERSION) {
|
|
||||||
throw new ToolError("errors.pipelineVersion", {
|
|
||||||
version: String(doc.version),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!Array.isArray(doc.steps)) {
|
|
||||||
throw new ToolError("errors.noSteps");
|
|
||||||
}
|
|
||||||
const steps: PipelineStep[] = [];
|
|
||||||
for (const rawStep of doc.steps) {
|
|
||||||
const step = validateStep(rawStep);
|
|
||||||
if (step) {
|
|
||||||
steps.push(step);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateStep(raw: unknown): PipelineStep | null {
|
|
||||||
if (typeof raw !== "object" || raw === null) return null;
|
|
||||||
const candidate = raw as { id?: unknown; toolId?: unknown; values?: unknown };
|
|
||||||
if (typeof candidate.toolId !== "string") return null;
|
|
||||||
const tool = getTool(candidate.toolId);
|
|
||||||
if (
|
|
||||||
!tool ||
|
|
||||||
typeof candidate.values !== "object" ||
|
|
||||||
candidate.values === null
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
const values = candidate.values as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id:
|
|
||||||
typeof candidate.id === "string" && candidate.id.length > 0
|
|
||||||
? candidate.id
|
|
||||||
: newStepId(),
|
|
||||||
toolId: candidate.toolId,
|
|
||||||
values: sanitizeParams(tool, values),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSteps(steps: PipelineStep[]): void {
|
|
||||||
if (typeof localStorage === "undefined") return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({ version: PIPELINE_VERSION, steps }),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// переполнение квоты или приватный режим — сохранение необязательно для работы
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadStoredSteps(): PipelineStep[] {
|
|
||||||
if (typeof localStorage === "undefined") return [];
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return [];
|
|
||||||
return parseDocument(raw);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export const TOOL_POPULARITY: Record<string, number> = {
|
|
||||||
"crop-png": 92,
|
|
||||||
"resize-png": 90,
|
|
||||||
"remove-background-png": 95,
|
|
||||||
"blur-png": 85,
|
|
||||||
"sharpen-png": 80,
|
|
||||||
"convert-png-to-jpg": 82,
|
|
||||||
"jpg-to-png": 78,
|
|
||||||
"rotate-png": 75,
|
|
||||||
"flip-png": 70,
|
|
||||||
"convert-png-to-webp": 68,
|
|
||||||
"webp-to-png": 62,
|
|
||||||
"grayscale-png": 60,
|
|
||||||
"adjust-brightness-contrast-png": 58,
|
|
||||||
"invert-colors-png": 52,
|
|
||||||
"remove-color-from-png": 65,
|
|
||||||
"change-png-opacity": 45,
|
|
||||||
"sepia-png": 40,
|
|
||||||
"change-png-hue": 38,
|
|
||||||
"add-padding-png": 42,
|
|
||||||
"add-border-png": 38,
|
|
||||||
"two-colors-png": 35,
|
|
||||||
"black-and-white-png": 42,
|
|
||||||
"posterize-png": 30,
|
|
||||||
"round-corners-png": 36,
|
|
||||||
"fit-on-background-png": 34,
|
|
||||||
"extract-channel-png": 24,
|
|
||||||
"swap-channels-png": 22,
|
|
||||||
"invert-alpha-png": 26,
|
|
||||||
"remove-alpha-channel-png": 30,
|
|
||||||
"set-alpha-channel-png": 24,
|
|
||||||
"extract-alpha-mask-png": 20,
|
|
||||||
"tile-png": 28,
|
|
||||||
"center-by-alpha-png": 26,
|
|
||||||
"png-info": 44,
|
|
||||||
"png-is-transparent": 18,
|
|
||||||
"png-is-grayscale": 14,
|
|
||||||
"png-orientation": 16,
|
|
||||||
"png-to-base64": 40,
|
|
||||||
"base64-to-png": 32,
|
|
||||||
"png-to-data-uri": 34,
|
|
||||||
"data-uri-to-png": 26,
|
|
||||||
"png-to-hex": 10,
|
|
||||||
"hex-to-png": 8,
|
|
||||||
"gif-to-png": 30,
|
|
||||||
"bmp-to-png": 18,
|
|
||||||
"ico-to-png": 20,
|
|
||||||
"png-to-bmp": 12,
|
|
||||||
"create-empty-png": 25,
|
|
||||||
"single-color-png": 20,
|
|
||||||
"random-noise-png": 12,
|
|
||||||
"linear-gradient-png": 22,
|
|
||||||
};
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
|
|
||||||
import { LOCALES, type Locale } from "$lib/v1/i18n/dict";
|
|
||||||
import { getLocale, initLocale, setLocale } from "$lib/v1/i18n/locale.svelte";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import "../../app_v1.css";
|
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
initLocale();
|
|
||||||
initTheme();
|
|
||||||
});
|
|
||||||
|
|
||||||
const LANG_LABELS: Record<Locale, string> = { ru: "RU", en: "EN" };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<link rel="icon" href={`${resolve("/")}favicon.svg`} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="app">
|
|
||||||
<header>
|
|
||||||
<a href={resolve("/v1")} class="brand">easy-png-tools</a>
|
|
||||||
<nav aria-label={t("header.sectionsAria")}>
|
|
||||||
<a class="nav-link workspace-link" href={resolve("/v1")}>
|
|
||||||
{t("header.workspace")}
|
|
||||||
</a>
|
|
||||||
<a class="nav-link" href={resolve("/v1/list-tools")}>
|
|
||||||
{t("header.catalog")}
|
|
||||||
</a>
|
|
||||||
<div class="lang-switch" role="group" aria-label="Language / Язык">
|
|
||||||
{#each LOCALES as l (l)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="lang-btn"
|
|
||||||
class:active={getLocale() === l}
|
|
||||||
aria-pressed={getLocale() === l}
|
|
||||||
onclick={() => setLocale(l)}
|
|
||||||
>
|
|
||||||
{LANG_LABELS[l]}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="theme-switch" role="group" aria-label="Theme / Тема">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="lang-btn"
|
|
||||||
class:active={getTheme() === "light"}
|
|
||||||
aria-pressed={getTheme() === "light"}
|
|
||||||
title={t("ui.themeLight")}
|
|
||||||
aria-label={t("ui.themeLight")}
|
|
||||||
onclick={() => setTheme("light")}
|
|
||||||
>
|
|
||||||
☀
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="lang-btn"
|
|
||||||
class:active={getTheme() === "dark"}
|
|
||||||
aria-pressed={getTheme() === "dark"}
|
|
||||||
title={t("ui.themeDark")}
|
|
||||||
aria-label={t("ui.themeDark")}
|
|
||||||
onclick={() => setTheme("dark")}
|
|
||||||
>
|
|
||||||
☾
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{@render children()}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p class="text-caption text-muted">{t("header.footerNote")}</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.app {
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-s);
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
color: var(--link);
|
|
||||||
background: var(--bg);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-switch,
|
|
||||||
.theme-switch {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
margin-left: var(--space-2);
|
|
||||||
padding: 2px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-btn {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-s);
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: calc(var(--radius-s) - 1px);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-btn:hover {
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-link {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-4) var(--space-5);
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { toolTitle } from "$lib/v1/i18n/tool-strings";
|
|
||||||
import ToolPage from "$lib/v1/components/ToolPage.svelte";
|
|
||||||
import ToolSearch from "$lib/v1/components/search/ToolSearch.svelte";
|
|
||||||
import Button from "$lib/v1/components/ui/Button.svelte";
|
|
||||||
import { getTool } from "$lib/v1/registry";
|
|
||||||
|
|
||||||
const LAST_TOOL_KEY = "last-tool-id";
|
|
||||||
|
|
||||||
function loadLastToolId(): string | null {
|
|
||||||
if (typeof localStorage === "undefined") return null;
|
|
||||||
try {
|
|
||||||
const id = localStorage.getItem(LAST_TOOL_KEY);
|
|
||||||
return id !== null && getTool(id) ? id : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedId = $state<string | null>(null);
|
|
||||||
let restoreOnOpen = $state(false);
|
|
||||||
let lastToolId = $state<string | null>(loadLastToolId());
|
|
||||||
|
|
||||||
function openTool(id: string, restore = false) {
|
|
||||||
if (!getTool(id)) return;
|
|
||||||
selectedId = id;
|
|
||||||
restoreOnOpen = restore;
|
|
||||||
lastToolId = id;
|
|
||||||
if (typeof localStorage === "undefined") return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LAST_TOOL_KEY, id);
|
|
||||||
} catch {
|
|
||||||
// приватный режим — просто не запоминаем
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeTool() {
|
|
||||||
selectedId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = $derived(
|
|
||||||
selectedId !== null ? (getTool(selectedId) ?? null) : null,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>
|
|
||||||
{selected ? `${selected.title} — easy-png-tools` : t("home.defaultTitle")}
|
|
||||||
</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{#if !selected}
|
|
||||||
<section class="hero">
|
|
||||||
<h1>{t("home.heroTitle")}</h1>
|
|
||||||
<p class="lead text-muted">{t("home.heroLead")}</p>
|
|
||||||
<ToolSearch onSelect={(id) => openTool(id)} />
|
|
||||||
{#if lastToolId !== null && getTool(lastToolId)}
|
|
||||||
{@const restoreId = lastToolId}
|
|
||||||
<div class="restore-row">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
fullWidth
|
|
||||||
onclick={() => openTool(restoreId, true)}
|
|
||||||
>
|
|
||||||
{t("home.restoreLast", {
|
|
||||||
title: getTool(restoreId) ? toolTitle(getTool(restoreId)!) : "",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{:else}
|
|
||||||
<section class="workbench">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="back text-caption text-muted"
|
|
||||||
onclick={closeTool}
|
|
||||||
>
|
|
||||||
{t("home.changeTool")}
|
|
||||||
</button>
|
|
||||||
{#key selectedId}
|
|
||||||
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
|
|
||||||
{/key}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hero {
|
|
||||||
text-align: center;
|
|
||||||
padding-top: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lead {
|
|
||||||
max-width: 40rem;
|
|
||||||
margin: 0 auto var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.restore-row {
|
|
||||||
max-width: 36rem;
|
|
||||||
margin: var(--space-2) auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back:hover {
|
|
||||||
color: var(--link);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ToolPage from "$lib/v1/components/ToolPage.svelte";
|
|
||||||
import { getTool } from "$lib/v1/registry";
|
|
||||||
|
|
||||||
const base = getTool("linear-gradient-png")!;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Mockup: цепочка инструментов — easy-png-tools</title>
|
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="demo">
|
|
||||||
<ToolPage
|
|
||||||
tool={base}
|
|
||||||
stageVariant="inline"
|
|
||||||
presetBaseValues={{
|
|
||||||
width: 520,
|
|
||||||
height: 340,
|
|
||||||
fromColor: "#e5484d",
|
|
||||||
toColor: "#fbbf24",
|
|
||||||
direction: "horizontal",
|
|
||||||
}}
|
|
||||||
presetChain={[
|
|
||||||
{
|
|
||||||
toolId: "remove-background-png",
|
|
||||||
values: { color: "#fbbf24", tolerance: 10, outerOnly: true, smooth: 2 },
|
|
||||||
},
|
|
||||||
{ toolId: "add-stroke-png", values: { color: "#2563eb", thickness: 8 } },
|
|
||||||
{ toolId: "round-corners-png", values: { radius: 28 } },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.demo {
|
|
||||||
max-width: 100rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from "$app/paths";
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
|
|
||||||
import { CATEGORIES } from "$lib/v1/categories";
|
|
||||||
import ToolCard from "$lib/v1/components/search/ToolCard.svelte";
|
|
||||||
import { TOOLS } from "$lib/v1/registry";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{t("catalog.pageTitle")}</title>
|
|
||||||
<meta name="description" content={t("catalog.metaDescription")} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>{t("catalog.heading")}</h1>
|
|
||||||
<p class="lead text-muted">{t("catalog.lead", { count: TOOLS.length })}</p>
|
|
||||||
|
|
||||||
{#each CATEGORIES as category (category)}
|
|
||||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category)}
|
|
||||||
{#if categoryTools.length > 0}
|
|
||||||
<section
|
|
||||||
id={category}
|
|
||||||
class="category"
|
|
||||||
aria-labelledby="{category}-heading"
|
|
||||||
>
|
|
||||||
<h2 id="{category}-heading" class="heading-section">
|
|
||||||
{t(`categories.${category}`)}
|
|
||||||
</h2>
|
|
||||||
<div class="grid">
|
|
||||||
{#each categoryTools as tool (tool.id)}
|
|
||||||
<div class="panel">
|
|
||||||
<ToolCard
|
|
||||||
toolId={tool.id}
|
|
||||||
title={toolTitle(tool)}
|
|
||||||
description={toolDescription(tool)}
|
|
||||||
href={resolve(`/tools/${tool.id}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
h1 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lead {
|
|
||||||
max-width: 48rem;
|
|
||||||
margin-bottom: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category {
|
|
||||||
margin-bottom: var(--space-5);
|
|
||||||
scroll-margin-top: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid > .panel {
|
|
||||||
padding: var(--space-2) var(--space-2) var(--space-3);
|
|
||||||
transition:
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
box-shadow var(--transition-fast),
|
|
||||||
transform var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid > .panel:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
|
|
||||||
import ToolPage from "$lib/v1/components/ToolPage.svelte";
|
|
||||||
import { getTool } from "$lib/v1/registry";
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
const tool = $derived(getTool(data.id));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title
|
|
||||||
>{tool ? toolTitle(tool) : t("toolPage.fallbackTitle")} — easy-png-tools</title
|
|
||||||
>
|
|
||||||
{#if tool}
|
|
||||||
<meta name="description" content={toolDescription(tool)} />
|
|
||||||
{/if}
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{#if tool}
|
|
||||||
<ToolPage {tool} />
|
|
||||||
{/if}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { t } from "$lib/v1/i18n/t";
|
|
||||||
import { getTool, TOOLS } from "$lib/v1/registry";
|
|
||||||
import { error } from "@sveltejs/kit";
|
|
||||||
import type { EntryGenerator, PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const entries: EntryGenerator = () =>
|
|
||||||
TOOLS.map((tool) => ({ id: tool.id }));
|
|
||||||
|
|
||||||
export const load: PageLoad = ({ params }) => {
|
|
||||||
const tool = getTool(params.id);
|
|
||||||
if (!tool) {
|
|
||||||
error(404, t("errors.notFound"));
|
|
||||||
}
|
|
||||||
return { id: tool.id };
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
// Stylelint config — design-token enforcement for easy-png-tools.
|
// Stylelint config — design-token enforcement for easy-png-tools.
|
||||||
// FWHM: keeps the design system a single source of truth. Colors and sizes must
|
// FWHM: keeps the design system a single source of truth. Colors and sizes must
|
||||||
// come from prefixed tokens; direct color values are allowed only for the two
|
// come from prefixed tokens; direct color values are allowed only for the two
|
||||||
// brand tokens. app_v1.css is the legacy design and is ignored (removed later).
|
// brand tokens.
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
extends: ["stylelint-config-standard"],
|
extends: ["stylelint-config-standard"],
|
||||||
ignoreFiles: [
|
ignoreFiles: [
|
||||||
"src/app_v1.css",
|
|
||||||
"**/node_modules/**",
|
"**/node_modules/**",
|
||||||
"**/build/**",
|
"**/build/**",
|
||||||
"**/.svelte-kit/**",
|
"**/.svelte-kit/**",
|
||||||
@@ -52,8 +51,6 @@ export default {
|
|||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
// Applies to every CSS file EXCEPT app_v1.css (already in ignoreFiles) —
|
|
||||||
// the legacy design is exempt and will be removed later.
|
|
||||||
files: ["src/**/*.css"],
|
files: ["src/**/*.css"],
|
||||||
rules: {
|
rules: {
|
||||||
// Rule: only --brand-main and --brand-alt may set a color
|
// Rule: only --brand-main and --brand-alt may set a color
|
||||||
|
|||||||
Reference in New Issue
Block a user