mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e75b5589 | ||
|
|
40338544de | ||
|
|
7f26498317 | ||
|
|
92cc590959 | ||
|
|
ba533e67fd | ||
|
|
a0bc72ef5d | ||
|
|
0753c95583 | ||
|
|
684423de67 | ||
|
|
7763fb7faa | ||
|
|
f00dae8b01 | ||
|
|
2a282b48f8 | ||
|
|
a9327eaa27 |
@@ -2,6 +2,20 @@
|
|||||||
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
|
||||||
|
|
||||||
- - -
|
- - -
|
||||||
|
## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06
|
||||||
|
#### Features
|
||||||
|
- add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK
|
||||||
|
- add transparency range slider to opacity fields - ([ba533e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba533e67fdba5e6b40f21c6b3ca375cf16426fef)) - Ku6epXBOCTuK
|
||||||
|
- upload image on click, tooltip - ([a0bc72e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a0bc72ef5d06b65507d6fe2ee3150872dbc146ae)) - Ku6epXBOCTuK
|
||||||
|
- split to parts show sizes of parts - ([684423d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/684423de679fc1b65195bd4fb1ce43290577722e)) - Ku6epXBOCTuK
|
||||||
|
#### Bug Fixes
|
||||||
|
- max height for source preview - ([0753c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0753c95583e83adb9fa25779bb5263a5cfe93ba2)) - Ku6epXBOCTuK
|
||||||
|
- scroll in result for split parts - ([7763fb7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7763fb7faa790c2e41f85362d375f576f28a4de1)) - Ku6epXBOCTuK
|
||||||
|
- range slider drag after regression - ([f00dae8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f00dae8b0158b9ff779511e1dd46cda8f5b6cb14)) - Ku6epXBOCTuK
|
||||||
|
- update split into parts max params - ([2a282b4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2a282b48f8d6be2bd3a4079f3a1a77031072b970)) - Ku6epXBOCTuK
|
||||||
|
|
||||||
|
- - -
|
||||||
|
|
||||||
## [v0.2.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f40a7c89ae5615f2c16f1154116d35fdc0e53125..v0.2.0) - 2026-10-04
|
## [v0.2.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f40a7c89ae5615f2c16f1154116d35fdc0e53125..v0.2.0) - 2026-10-04
|
||||||
#### Features
|
#### Features
|
||||||
- add pipeline flow - ([6b946bf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b946bfb08729ead2515e87406cb3efd22ca9859)) - Ku6epXBOCTuK
|
- add pipeline flow - ([6b946bf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b946bfb08729ead2515e87406cb3efd22ca9859)) - Ku6epXBOCTuK
|
||||||
|
|||||||
+196
-201
@@ -3,83 +3,88 @@
|
|||||||
> Статус: **active**. Открытые задачи. Подробные планы, разбор решений и сырые
|
> Статус: **active**. Открытые задачи. Подробные планы, разбор решений и сырые
|
||||||
> находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
|
> находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
|
||||||
> `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
|
> `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
|
||||||
|
>
|
||||||
|
> Все пункты — чекбоксы; нумерация «Идея N» в тексте сквозная по документу и
|
||||||
|
> сохраняется ради перекрёстных ссылок («идея 4 зависит от идеи 3»). Оценки
|
||||||
|
> (S/M/L) — оценка труда, не приоритет.
|
||||||
|
|
||||||
## lint и прочие правила
|
## Pipeline
|
||||||
|
|
||||||
- [ ] `tseslint.config(...)` deprecated: сигнатура
|
- [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
|
||||||
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
|
но он работает с любыми картинками, наверное надо убрать; в заголовке
|
||||||
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
|
страницы — возможно, надо указать все форматы (связано с SEO)
|
||||||
core, а не `typescript-eslint`) — `web/eslint.config.js`.
|
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
|
||||||
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
|
|
||||||
до этого действует pattern-based policy-исключение
|
|
||||||
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
|
|
||||||
|
|
||||||
## Мелочи всякие
|
|
||||||
|
|
||||||
- [ ] названия инструментов - сейчас в pipeline инструмент называется "обрезать
|
|
||||||
png", но он работает с любыми картинками, наверное надо убрать. но в
|
|
||||||
заголовке - возможно надо все форматы указать (SEO задача?)
|
|
||||||
- [ ] кнопка добавить инструмент в pipeline - обдумать селектор с номальным
|
|
||||||
поиском, фильтром возможных инструментов и т.д.
|
поиском, фильтром возможных инструментов и т.д.
|
||||||
- [ ] pipeline - промежуточные результаты
|
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
|
||||||
- [ ] pipeline - drag-n-drop инструментов
|
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
|
||||||
- [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
|
(любой шаг, не только первый), остальные шаги и их параметры сохраняются,
|
||||||
нарезки ассетов
|
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
|
||||||
|
(text-source ↔ file).
|
||||||
|
- [ ] **Идея 2. Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания
|
||||||
|
звена до заголовка «Шаг n: название» (превью скрываются). Состояние
|
||||||
|
свёрнутости помнить в workspace-pipeline.
|
||||||
|
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
|
||||||
|
Именованные цепочки в localStorage (список, создать/переименовать/
|
||||||
|
удалить), быстрое переключение. Формат экспортного JSON расширить
|
||||||
|
опциональным полем имени; обратной совместимости со старыми выгрузками не
|
||||||
|
делаем — формат не устоялся: он появится вместе с фазой 11
|
||||||
|
`docs/roadmap.md` и до её старта может измениться. Плюс отдельная страница
|
||||||
|
со списком всех цепочек: удалить, переименовать, добавить новую,
|
||||||
|
«использовать» (переход на страницу инструмента с этой цепочкой). На
|
||||||
|
странице — упрощённый вид: только список инструментов цепочки, без
|
||||||
|
реального выполнения и параметров.
|
||||||
|
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход —
|
||||||
|
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного
|
||||||
|
сохранённого chain по каждому файлу, сборка результата обратно в zip для
|
||||||
|
скачивания. Зависит от идеи 3 (выбор цепочки) и от экспортного формата
|
||||||
|
pipeline JSON; прогресс и ошибки — пофайлово.
|
||||||
|
- [ ] **Идея 15. Набор файлов в пайплайне: 1 → many не должен быть
|
||||||
|
терминальным** — оценка L. **Сейчас** `execute` принимает один
|
||||||
|
`source: PixelImage | undefined` и возвращает один `ToolResult`, а
|
||||||
|
`SchemaToolView` раскладывает вывод в `result` / `fileResult` /
|
||||||
|
`textResult` по `resultKind` — поэтому шаг с `result: "files"` упирается в
|
||||||
|
конец цепочки. **Направление:** следующий инструмент применяется **ко всем
|
||||||
|
картинкам** предыдущего результата (fan-out), а many → 1 (вход = архив или
|
||||||
|
несколько файлов) — обратная сторона того же контракта. Терминальными
|
||||||
|
остаются только результаты, которые не картинка (text, verdict). Что
|
||||||
|
обдумать: - где проходит граница: один вызов `run(ctx)` на файл (имя части
|
||||||
|
склеивается в `FileResult.files`) или батч целиком на стороне исполнителя;
|
||||||
|
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
|
||||||
|
прогресс и отмена; - частичные ошибки: один битый файл не должен ронять
|
||||||
|
весь шаг — нужен per-file отчёт, а не один `ToolError` на шаг; - имена
|
||||||
|
частей на каждом шаге и Download для набора (сейчас zip по `page.slug`,
|
||||||
|
`SchemaToolView.download()`); - обратная сторона: распаковка zip в
|
||||||
|
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
|
||||||
|
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
|
||||||
|
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
|
||||||
|
- [ ] **Идея 17. Промежуточные результаты шагов + выравнивание рядов** — оценка
|
||||||
|
M. Два режима просмотра цепочки: - **без выравнивания (по умолчанию):** в
|
||||||
|
панели просмотра цепочка изображений по 2 в колонку «исходник → результат
|
||||||
|
1 → результат 2 → … → конечный результат»; - **с выравниванием (галочка
|
||||||
|
«выровнять инструменты и просмотр»):** у каждого инструмента показаны его
|
||||||
|
исходник и результат построчно «параметры — исходник — результат»;
|
||||||
|
промежуточные картинки при этом осознанно дублируются на экране —
|
||||||
|
результат шага является исходником следующего.
|
||||||
|
|
||||||
|
## Инструменты
|
||||||
|
|
||||||
|
- [ ] qr code to text, text to qr code
|
||||||
|
- [ ] bar code to text, text to bar code
|
||||||
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
|
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
|
||||||
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
|
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
|
||||||
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
|
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
|
||||||
схема + core, из задачи C3)
|
схема + core, из задачи C3)
|
||||||
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
|
||||||
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
|
||||||
(`docs/testing-strategy.md`, раздел 5)
|
|
||||||
- [ ] e2e webkit: случайные падения по таймауту 5с (элемент не появляется),
|
|
||||||
каждый прогон падают разные тесты — webkit под Windows слишком медленный.
|
|
||||||
Рассмотреть увеличение timeout/expectTimeout для webkit-проекта в
|
|
||||||
`playwright.config.ts`. После фикса перепроверить `tool-flows.spec.ts`
|
|
||||||
«chain: remove step» — на webkit удаление шага не успевает за 5с (на
|
|
||||||
остальных движках стабильно)
|
|
||||||
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
|
||||||
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
|
||||||
схемой (FA-E1), либо удалить ключ из обеих локалей
|
|
||||||
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
|
||||||
привязанные к разметке или svelte-классам (сейчас остались:
|
|
||||||
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
|
||||||
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
|
||||||
правило такое (запретить toContain без i18n)
|
|
||||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||||
выделенной области
|
выделенной области
|
||||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||||
какой размер - 112\56\28px или 72\36\18)
|
какой размер - 112\56\28px или 72\36\18)
|
||||||
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
|
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
|
||||||
файлов, посмотреть реализацию в
|
файлов, посмотреть реализацию в
|
||||||
[https://github.com/tma02/twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
|
[twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
|
||||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
|
||||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
|
||||||
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
|
|
||||||
и т.д., сделать lint правило
|
|
||||||
- [ ] комментарии на английском, lint правило
|
|
||||||
- [ ] уменьшить количество и многословность комментариев (lint правило?)
|
|
||||||
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
|
||||||
должны быть настраиваемой 0-100%, а не переключателем
|
|
||||||
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
|
||||||
единую структуру, позволит упростить некоторые вещи - например
|
|
||||||
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
|
|
||||||
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
|
||||||
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
|
||||||
`ondownloadtxt`, `ondownload`,
|
|
||||||
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
|
||||||
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
|
||||||
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
|
||||||
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
||||||
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
||||||
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
||||||
паспортом, семейства с кириллицей)
|
паспортом, семейства с кириллицей)
|
||||||
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
|
||||||
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
|
||||||
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
|
||||||
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
|
||||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
|
||||||
- [ ] обновить страницу kit или сделать storybook
|
|
||||||
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
|
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
|
||||||
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
|
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
|
||||||
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
|
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
|
||||||
@@ -87,6 +92,87 @@
|
|||||||
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
|
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
|
||||||
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
|
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
|
||||||
вывода
|
вывода
|
||||||
|
- [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке
|
||||||
|
Download** — оценка M/L. Решение и продуктовое обоснование:
|
||||||
|
`docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет
|
||||||
|
существующие convert-страницы — `docs/plan-seo.md` §5. Механическая часть
|
||||||
|
уже есть: `Tool.output` (`OutputFormat`: mime/ext/qualityParamId) и
|
||||||
|
кодирование по нему в `SchemaToolView.download()` —
|
||||||
|
`docs/architecture.md`, раздел 3. Что осталось сверх S1f: - экспорт с
|
||||||
|
лимитом размера → «сжатие до целевого КБ»: для lossy это quality в
|
||||||
|
селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
|
||||||
|
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
|
||||||
|
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
|
||||||
|
download-флоу.
|
||||||
|
- [ ] **Идея 7. Страница `watermark-image-png` (водяной знак картинкой)** —
|
||||||
|
оценка M. Нужна overlay-механика: второй источник изображения
|
||||||
|
(getOverlay/store), параметры scale/opacity/position/margin/aspect.
|
||||||
|
Решается вместе с вопросом, как второй источник картинки вписывается в
|
||||||
|
schema-driven preview (`SchemaToolView`).
|
||||||
|
- [ ] **Идея 8. `png-info` (детальная информация о PNG)** — оценка M/L.
|
||||||
|
Отдельный райз: exif-теги, редактирование, структурированный вывод; нужен
|
||||||
|
свой отдельный случай в UI, не «ещё один text/verdict». Возможно нужна
|
||||||
|
вообще отдельная страница, несовместимая с другими инструментами: 1) она
|
||||||
|
работает не с изображением, а с метаданными из файла, 2) она не может быть
|
||||||
|
частью цепочки, т.к. результат не является изображением, 3) если надо
|
||||||
|
изменить теги, никакого форматирования и изменения в изображение вносить
|
||||||
|
не надо, только в метаинформацию.
|
||||||
|
- [ ] **Идея 9. Region-инструменты** (censor/erase/pixelate-area/blur-area/
|
||||||
|
sharpen-area/reverse-area) — ждут UI выделения области на превью. Вне
|
||||||
|
очереди.
|
||||||
|
- [ ] **Идея 10. Мультифайловые инструменты-потребители** (gif-to-frames,
|
||||||
|
separate-colors) — механизм «результат = набор файлов» (1 → many) уже есть
|
||||||
|
(`docs/architecture.md`, раздел 7), первый потребитель —
|
||||||
|
`split-into-parts-png`. Остальные подключаются по мере нужды.
|
||||||
|
- [ ] **Идея 11. Анимационные** (slow-reveal/fade/scrolling) — выход не PNG;
|
||||||
|
отдельное решение о формате.
|
||||||
|
- [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract,
|
||||||
|
handwritten-digital).
|
||||||
|
- [ ] **Идея 14. Отображение маски для инструментов (`runMask`)** — придумать и
|
||||||
|
реализовать процесс показа маски результата. **Контекст:** удалённый UI-v1
|
||||||
|
умел показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
|
||||||
|
переходе на единый `run(ctx)` этого поля не осталось — executor и
|
||||||
|
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
|
||||||
|
дать каждому `run` возможность вернуть (или инструмент — объявить)
|
||||||
|
`runMask` — ч/б изображение маски изменений либо иной подход (отдельное
|
||||||
|
поле `mask` + функция маски, второй результат, overlay). Оценка — на
|
||||||
|
усмотрение при проработке (S/M). Пока живём без неё. **Идея 2:** каждый
|
||||||
|
`run` возвращает два изображения — для отображения (показывает маску или
|
||||||
|
сам результат) и для передачи в следующий инструмент (всегда результат).
|
||||||
|
Оценка — на усмотрение при проработке (S/M). Пока живём без неё. Первые
|
||||||
|
кандидаты на маску: `remove-color-from-png`, `remove-background-png`.
|
||||||
|
|
||||||
|
## UI и качество
|
||||||
|
|
||||||
|
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
||||||
|
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
||||||
|
схемой (FA-E1), либо удалить ключ из обеих локалей
|
||||||
|
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||||
|
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||||
|
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
|
||||||
|
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
|
||||||
|
черная\белая\цветные\серая для проверки прозрачных картинок
|
||||||
|
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
||||||
|
единую структуру, позволит упростить некоторые вещи - например
|
||||||
|
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
|
||||||
|
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
||||||
|
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
||||||
|
`ondownloadtxt`, `ondownload`,
|
||||||
|
- [ ] resize keep aspect - работает, но немного не очевидное поведение при
|
||||||
|
прямоугольной картинке и выборе размера через быстрые кнопки (например
|
||||||
|
256х256). Воспроизведение поведения: keep aspect включено, загружаем
|
||||||
|
прямоугольную картинку 832 × 1232, картинка рескейлится до 173 × 256,
|
||||||
|
выключаем keep aspect, картинка рескейлится до 256 × 256 (с искажением
|
||||||
|
пропорций), включаем keep aspect - картинка рескейлится до размера 256 ×
|
||||||
|
379
|
||||||
|
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
||||||
|
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
||||||
|
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
||||||
|
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
||||||
|
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
||||||
|
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
||||||
|
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
||||||
|
- [ ] обновить страницу kit или сделать storybook
|
||||||
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
|
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
|
||||||
поэтому она не следует за пропорциями результата — широкий результат
|
поэтому она не следует за пропорциями результата — широкий результат
|
||||||
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
|
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
|
||||||
@@ -95,20 +181,51 @@
|
|||||||
- [ ] Радиусы бордеров: `--radius-m` ненулевой и применён ко всем бордерам
|
- [ ] Радиусы бордеров: `--radius-m` ненулевой и применён ко всем бордерам
|
||||||
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
|
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
|
||||||
в дизайне, а где должен быть 0
|
в дизайне, а где должен быть 0
|
||||||
|
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
||||||
|
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
||||||
|
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
||||||
|
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
|
||||||
|
- [ ] **Идея 5. Избранные инструменты (fav tools)** — оценка S/M.
|
||||||
|
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке
|
||||||
|
страницы инструмента; список slug в localStorage. Избранное показывается
|
||||||
|
отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к
|
||||||
|
popularity при скоринге).
|
||||||
|
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
||||||
|
`--color-main`/`--color-accent` подняты под текущий порог
|
||||||
|
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
|
||||||
|
для обычного текста, а `--color-danger` в light-теме на границе: после
|
||||||
|
завершения редизайна вернуть порог 4.5 и перепроверить оба токена.
|
||||||
|
- [ ] **Идея 16. Главная должна отличаться от каталога** — оценка S. Сейчас
|
||||||
|
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
|
||||||
|
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
|
||||||
|
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
|
||||||
|
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
|
||||||
|
Замысел: главная — крупнее и с более простым поиском, без категорий;
|
||||||
|
каталог остаётся группировкой с фильтрами. Детектор дублирования разметки
|
||||||
|
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
|
||||||
|
ловится на ревью.
|
||||||
|
|
||||||
## Инструменты
|
## Тесты и lint
|
||||||
|
|
||||||
- [ ] qr code to text, text to qr code
|
- [ ] `tseslint.config(...)` deprecated: сигнатура
|
||||||
- [ ] bar code to text, text to bar code
|
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
|
||||||
|
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
|
||||||
## Переезд
|
core, а не `typescript-eslint`) — `web/eslint.config.js`.
|
||||||
|
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
|
||||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
до этого действует pattern-based policy-исключение
|
||||||
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
|
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
|
||||||
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
|
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
||||||
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
|
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
||||||
plurals есть все формы, для которых нужен перевод, `other` никогда не
|
(`docs/testing-strategy.md`, раздел 5)
|
||||||
пустой, число подставляется в текст без склейки
|
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
||||||
|
привязанные к разметке или svelte-классам (сейчас остались:
|
||||||
|
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
||||||
|
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
||||||
|
правило такое (запретить toContain без i18n)
|
||||||
|
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
|
||||||
|
и т.д., сделать lint правило
|
||||||
|
- [ ] комментарии на английском, lint правило
|
||||||
|
- [ ] уменьшить количество и многословность комментариев (lint правило?)
|
||||||
|
|
||||||
## SEO/GEO
|
## SEO/GEO
|
||||||
|
|
||||||
@@ -139,133 +256,11 @@
|
|||||||
- [ ] Шрифты: лицензирование (F1–F4) и DOM-free рендер (F5–F6) — до фазы 8
|
- [ ] Шрифты: лицензирование (F1–F4) и DOM-free рендер (F5–F6) — до фазы 8
|
||||||
(`docs/plan-platform.md` §4)
|
(`docs/plan-platform.md` §4)
|
||||||
|
|
||||||
## Качество production UI
|
## Переезд
|
||||||
|
|
||||||
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||||
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
|
||||||
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
|
||||||
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
|
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
|
||||||
|
plurals есть все формы, для которых нужен перевод, `other` никогда не
|
||||||
## Идеи
|
пустой, число подставляется в текст без склейки
|
||||||
|
|
||||||
Идеи 1–5 и 15 — про пайплайн (фаза 11 `docs/roadmap.md`). Идеи 6–14 и 16 — про
|
|
||||||
существующие инструменты и UI. Оценки (S/M/L) — оценка труда, не приоритет.
|
|
||||||
|
|
||||||
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
|
|
||||||
зафиксирован при открытии страницы; хочется заменить его на другой без
|
|
||||||
разборки цепочки: шаги и их параметры сохраняются, вход перечитывается.
|
|
||||||
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
|
|
||||||
file).
|
|
||||||
|
|
||||||
2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
|
|
||||||
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
|
|
||||||
помнить в workspace-pipeline.
|
|
||||||
|
|
||||||
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
|
|
||||||
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
|
|
||||||
Формат экспортного JSON расширить опциональным полем имени; обратной
|
|
||||||
совместимости со старыми выгрузками не делаем — формат не устоялся: он
|
|
||||||
появится вместе с фазой 11 `docs/roadmap.md` и до её старта может измениться.
|
|
||||||
|
|
||||||
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
|
|
||||||
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
|
|
||||||
chain по каждому файлу, сборка результата обратно в zip для скачивания.
|
|
||||||
Зависит от идеи 3 (выбор цепочки) и от экспортного формата pipeline JSON;
|
|
||||||
прогресс и ошибки — пофайлово.
|
|
||||||
|
|
||||||
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
|
|
||||||
карточках каталога, результатах поиска и в шапке страницы инструмента; список
|
|
||||||
slug в localStorage. Избранное показывается отдельной секцией сверху каталога
|
|
||||||
и поднимается в выдаче поиска (бонус к popularity при скоринге).
|
|
||||||
|
|
||||||
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
|
|
||||||
— оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и
|
|
||||||
задача **S1f**; почему селектор не заменяет существующие convert-страницы —
|
|
||||||
`docs/plan-seo.md` §5. Механическая часть уже есть: `Tool.output`
|
|
||||||
(`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в
|
|
||||||
`SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось
|
|
||||||
сверх S1f:
|
|
||||||
- экспорт с лимитом размера → «сжатие до целевого КБ»: для lossy это quality
|
|
||||||
в селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
|
|
||||||
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
|
|
||||||
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
|
|
||||||
download-флоу.
|
|
||||||
|
|
||||||
7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна
|
|
||||||
overlay-механика: второй источник изображения (getOverlay/store), параметры
|
|
||||||
scale/opacity/position/margin/aspect. Решается вместе с вопросом, как второй
|
|
||||||
источник картинки вписывается в schema-driven preview (`SchemaToolView`).
|
|
||||||
|
|
||||||
8. **`png-info` (детальная информация о PNG)** — оценка M/L. Отдельный райз:
|
|
||||||
exif-теги, редактирование, структурированный вывод; нужен свой отдельный
|
|
||||||
случай в UI, не «ещё один text/verdict». Возможно нужна вообще отдельная
|
|
||||||
страница, несовместимая с другими инструментами: 1) она работает не с
|
|
||||||
изображением, а с метаданными из файла, 2) она не может быть частью цепочки,
|
|
||||||
т.к. результат не является изображением, 3) если надо изменить теги, никакого
|
|
||||||
форматирования и изменения в изображение вносить не надо, только в
|
|
||||||
метаинформацию.
|
|
||||||
|
|
||||||
9. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
|
|
||||||
reverse-area) — ждут UI выделения области на превью. Вне очереди.
|
|
||||||
|
|
||||||
10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
|
|
||||||
— механизм «результат = набор файлов» (1 → many) уже есть
|
|
||||||
(`docs/architecture.md`, раздел 7), первый потребитель —
|
|
||||||
`split-into-parts-png`. Остальные подключаются по мере нужды.
|
|
||||||
|
|
||||||
11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
|
|
||||||
решение о формате.
|
|
||||||
|
|
||||||
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital).
|
|
||||||
|
|
||||||
13. **Контраст текста на акцентных кнопках — частично закрыт**. Тона
|
|
||||||
`--color-main`/`--color-accent` подняты под текущий порог `palette.test.ts`;
|
|
||||||
targeted-тест проходит. Light-тема всё ещё ниже AA 4.5 для обычного текста,
|
|
||||||
а `--color-danger` в light-теме на границе: после завершения редизайна
|
|
||||||
вернуть порог 4.5 и перепроверить оба токена.
|
|
||||||
|
|
||||||
14. **Отображение маски для инструментов (`runMask`)** — придумать и реализовать
|
|
||||||
процесс показа маски результата. **Контекст:** удалённый UI-v1 умел
|
|
||||||
показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
|
|
||||||
переходе на единый `run(ctx)` этого поля не осталось — executor и
|
|
||||||
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
|
|
||||||
дать каждому `run` возможность вернуть (или инструмент — объявить) `runMask`
|
|
||||||
— ч/б изображение маски изменений либо иной подход (отдельное поле `mask` +
|
|
||||||
функция маски, второй результат, overlay). Оценка — на усмотрение при
|
|
||||||
проработке (S/M). Пока живём без неё. **Идея 2:** каждый `run` возвращает
|
|
||||||
два изображения — для отображения (показывает маску или сам результат) и для
|
|
||||||
передачи в следующий инструмент (всегда результат). Оценка — на усмотрение
|
|
||||||
при проработке (S/M). Пока живём без неё. Первые кандидаты на маску:
|
|
||||||
`remove-color-from-png`, `remove-background-png`.
|
|
||||||
|
|
||||||
15. **Набор файлов в пайплайне: 1 → many не должен быть терминальным** — оценка
|
|
||||||
L. **Сейчас** `execute` принимает один `source: PixelImage | undefined` и
|
|
||||||
возвращает один `ToolResult`, а `SchemaToolView` раскладывает вывод в
|
|
||||||
`result` / `fileResult` / `textResult` по `resultKind` — поэтому шаг с
|
|
||||||
`result: "files"` упирается в конец цепочки. **Направление:** следующий
|
|
||||||
инструмент применяется **ко всем картинкам** предыдущего результата
|
|
||||||
(fan-out), а many → 1 (вход = архив или несколько файлов) — обратная сторона
|
|
||||||
того же контракта. Терминальными остаются только результаты, которые не
|
|
||||||
картинка (text, verdict). Что обдумать:
|
|
||||||
- где проходит граница: один вызов `run(ctx)` на файл (имя части склеивается
|
|
||||||
в `FileResult.files`) или батч целиком на стороне исполнителя;
|
|
||||||
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
|
|
||||||
прогресс и отмена;
|
|
||||||
- частичные ошибки: один битый файл не должен ронять весь шаг — нужен
|
|
||||||
per-file отчёт, а не один `ToolError` на шаг;
|
|
||||||
- имена частей на каждом шаге и Download для набора (сейчас zip по
|
|
||||||
`page.slug`, `SchemaToolView.download()`);
|
|
||||||
- обратная сторона: распаковка zip в браузере, порядок файлов, поведение
|
|
||||||
many → 1 → many в одной цепочке. Зависит от идеи 10 (остальные потребители
|
|
||||||
1 → many) и идеи 4 (batch по архиву), `docs/roadmap.md` фаза 8
|
|
||||||
«batch-страница».
|
|
||||||
|
|
||||||
16. **Главная должна отличаться от каталога** — оценка S. Сейчас
|
|
||||||
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
|
|
||||||
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
|
|
||||||
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
|
|
||||||
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
|
|
||||||
Замысел: главная — крупнее и с более простым поиском, без категорий; каталог
|
|
||||||
остаётся группировкой с фильтрами. Детектор дублирования разметки
|
|
||||||
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
|
|
||||||
ловится на ревью.
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "easy-png-tools",
|
"name": "easy-png-tools",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -56,12 +56,24 @@ const decodeContracts: readonly DecodeContract[] = [
|
|||||||
{ file: sixteenBitPng, rejectedIn: [] },
|
{ file: sixteenBitPng, rejectedIn: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// На этих файлах createImageBitmap в webkit не завершается: ни результата, ни
|
||||||
|
// ошибки — контракт «accept или reject» проверить нельзя.
|
||||||
|
const webkitHangs = new Set([
|
||||||
|
"truncated-header.png",
|
||||||
|
"crc-bad-ancillary.png",
|
||||||
|
"palette-256.png",
|
||||||
|
]);
|
||||||
|
|
||||||
test.describe("PNG special fixtures", () => {
|
test.describe("PNG special fixtures", () => {
|
||||||
for (const { file, rejectedIn } of decodeContracts) {
|
for (const { file, rejectedIn } of decodeContracts) {
|
||||||
test(`${file.name}: matches the engine decode contract`, async ({
|
test(`${file.name}: matches the engine decode contract`, async ({
|
||||||
browserName,
|
browserName,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
test.fixme(
|
||||||
|
browserName === "webkit" && webkitHangs.has(file.name),
|
||||||
|
"webkit createImageBitmap зависает на этом файле",
|
||||||
|
);
|
||||||
if (rejectedIn.includes(browserName)) {
|
if (rejectedIn.includes(browserName)) {
|
||||||
await expectRejected(page, file);
|
await expectRejected(page, file);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -295,7 +295,10 @@ test("chain: remove step returns to a single tool", async ({ page }) => {
|
|||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
await expect(stepTwo).toBeVisible();
|
await expect(stepTwo).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Remove step" }).last().click();
|
// Клик по remove именно в карточке Grayscale: порядок шагов зависит от
|
||||||
|
// тайминга рендера и на разных движках может отличаться.
|
||||||
|
const card = page.getByRole("article").filter({ hasText: "Grayscale PNG" });
|
||||||
|
await card.getByLabel("Remove step").click();
|
||||||
await expect(stepTwo).toHaveCount(0);
|
await expect(stepTwo).toHaveCount(0);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
name: "webkit",
|
name: "webkit",
|
||||||
testMatch: browserContractTests,
|
testMatch: browserContractTests,
|
||||||
|
// webkit под Windows заметно медленнее и под нагрузкой случайно
|
||||||
|
// превышает любые таймауты (каждый прогон — разные тесты): запас по
|
||||||
|
// таймаутам + retries против флака.
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 30_000 },
|
||||||
|
retries: 2,
|
||||||
use: {
|
use: {
|
||||||
browserName: "webkit",
|
browserName: "webkit",
|
||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 1440, height: 900 },
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
|
|
||||||
--color-main-tint: hct(from var(--brand-main) h 65 55);
|
--color-main-tint: hct(from var(--brand-main) h 65 55);
|
||||||
--color-main-soft: color-mix(in srgb, var(--color-main) 12%, transparent);
|
--color-main-soft: color-mix(in srgb, var(--color-main) 12%, transparent);
|
||||||
|
--color-scrim: color-mix(in srgb, var(--color-background) 65%, transparent);
|
||||||
--color-accent-tint: hct(from var(--brand-alt) h 65 55);
|
--color-accent-tint: hct(from var(--brand-alt) h 65 55);
|
||||||
--color-success-tint: hct(160 65 55);
|
--color-success-tint: hct(160 65 55);
|
||||||
--color-warning-tint: hct(80 65 55);
|
--color-warning-tint: hct(80 65 55);
|
||||||
|
|||||||
@@ -35,13 +35,17 @@
|
|||||||
|
|
||||||
<article
|
<article
|
||||||
class="step-card"
|
class="step-card"
|
||||||
{draggable}
|
|
||||||
ondragstart={(e) => ondragstart?.(e)}
|
|
||||||
ondragover={(e) => ondragover?.(e)}
|
ondragover={(e) => ondragover?.(e)}
|
||||||
ondrop={(e) => ondrop?.(e)}
|
ondrop={(e) => ondrop?.(e)}
|
||||||
ondragend={() => ondragend?.()}
|
|
||||||
>
|
>
|
||||||
<header class="step-head">
|
<header
|
||||||
|
class="step-head"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
{draggable}
|
||||||
|
ondragstart={(e) => ondragstart?.(e)}
|
||||||
|
ondragend={() => ondragend?.()}
|
||||||
|
>
|
||||||
<span class="step-index">{index.toString().padStart(2, "0")}</span>
|
<span class="step-index">{index.toString().padStart(2, "0")}</span>
|
||||||
<GripVertical size={16} class="drag" aria-hidden="true" />
|
<GripVertical size={16} class="drag" aria-hidden="true" />
|
||||||
<div class="step-heading-text">
|
<div class="step-heading-text">
|
||||||
|
|||||||
@@ -13,11 +13,25 @@
|
|||||||
label: string;
|
label: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
spec: FieldSpec;
|
spec: FieldSpec;
|
||||||
|
/** Размеры входа шага: потолок осей для maxFromSource-полей. */
|
||||||
|
sourceDims?: Dimension;
|
||||||
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
|
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
|
||||||
}
|
}
|
||||||
let { label, value, spec, onchange }: Props = $props();
|
let {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
spec,
|
||||||
|
sourceDims = undefined,
|
||||||
|
onchange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const sp = $derived(spec as DimensionSpec);
|
const sp = $derived(spec as DimensionSpec);
|
||||||
|
const maxW = $derived(
|
||||||
|
sp.maxFromSource && sourceDims ? sourceDims.width : sp.max,
|
||||||
|
);
|
||||||
|
const maxH = $derived(
|
||||||
|
sp.maxFromSource && sourceDims ? sourceDims.height : sp.max,
|
||||||
|
);
|
||||||
const current = $derived.by(() => {
|
const current = $derived.by(() => {
|
||||||
const v = value as Partial<Dimension> | undefined;
|
const v = value as Partial<Dimension> | undefined;
|
||||||
return {
|
return {
|
||||||
@@ -49,7 +63,7 @@
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={sp.min}
|
min={sp.min}
|
||||||
max={sp.max}
|
max={maxW}
|
||||||
value={current.width}
|
value={current.width}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
setAxis("width", Number((e.target as HTMLInputElement).value))}
|
setAxis("width", Number((e.target as HTMLInputElement).value))}
|
||||||
@@ -60,7 +74,7 @@
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={sp.min}
|
min={sp.min}
|
||||||
max={sp.max}
|
max={maxH}
|
||||||
value={current.height}
|
value={current.height}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
setAxis("height", Number((e.target as HTMLInputElement).value))}
|
setAxis("height", Number((e.target as HTMLInputElement).value))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { InputMode, ResultKind } from "$lib/registry";
|
import type { InputMode, ResultKind } from "$lib/registry";
|
||||||
import { RefreshCw } from "@lucide/svelte";
|
import { RefreshCw } from "@lucide/svelte";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
|
import Badge from "$lib/components/ui/Badge.svelte";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,6 +11,10 @@
|
|||||||
children: Snippet;
|
children: Snippet;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
parts?: number;
|
parts?: number;
|
||||||
|
partsDims?: string;
|
||||||
|
dims?: string;
|
||||||
|
note?: string;
|
||||||
|
noteTone?: "warning" | "info";
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
label,
|
label,
|
||||||
@@ -17,20 +22,32 @@
|
|||||||
children,
|
children,
|
||||||
loading = false,
|
loading = false,
|
||||||
parts = undefined,
|
parts = undefined,
|
||||||
|
partsDims = undefined,
|
||||||
|
dims = undefined,
|
||||||
|
note = undefined,
|
||||||
|
noteTone = "warning",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<figure class="tile">
|
<figure class="tile">
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<span>
|
<span class="tile-label">
|
||||||
{label}
|
<span
|
||||||
{#if parts !== undefined}
|
>{label}{#if parts !== undefined}<span class="dims"
|
||||||
<span class="count">{parts} {t("resultCard.parts")}</span>
|
>: {parts}
|
||||||
{/if}
|
{t("resultCard.parts")}{#if partsDims}
|
||||||
|
· {partsDims}{/if}</span
|
||||||
|
>{:else if dims}<span class="dims">: {dims}</span>{/if}</span
|
||||||
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<RefreshCw class="rotating" size="12" />
|
<RefreshCw class="rotating" size="12" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="tile-note">
|
||||||
|
{#if note}
|
||||||
|
<Badge tone={noteTone} label={note} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
<div class="canvas" class:checker={mode === "image"}>
|
<div class="canvas" class:checker={mode === "image"}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -41,7 +58,10 @@
|
|||||||
.tile figcaption {
|
.tile figcaption {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: var(--space-m);
|
gap: var(--space-m);
|
||||||
|
/* Резерв под бейдж: строка не прыгает, когда заметка появляется. */
|
||||||
|
min-height: var(--space-xxl);
|
||||||
margin-bottom: var(--space-m);
|
margin-bottom: var(--space-m);
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -50,8 +70,18 @@
|
|||||||
animation: rotate var(--duration-l) linear infinite;
|
animation: rotate var(--duration-l) linear infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.count {
|
.tile-label {
|
||||||
margin-left: var(--space-m);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
/* Место под заметки результата: бейджи-предупреждения. */
|
||||||
|
.tile-note {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
.dims {
|
||||||
color: var(--color-main);
|
color: var(--color-main);
|
||||||
}
|
}
|
||||||
.canvas {
|
.canvas {
|
||||||
@@ -69,7 +99,7 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 100vh;
|
max-height: 60vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {
|
import type {
|
||||||
|
Dimension,
|
||||||
FieldSpec,
|
FieldSpec,
|
||||||
FieldSpecKind,
|
FieldSpecKind,
|
||||||
ToolSchema,
|
ToolSchema,
|
||||||
} from "$lib/registry-schema";
|
} from "$lib/registry-schema";
|
||||||
import { resolveLayoutGroups } from "$lib/registry-schema";
|
import { effectiveMax, resolveLayoutGroups } from "$lib/registry-schema";
|
||||||
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
|
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { RotateCcw } from "@lucide/svelte";
|
import { RotateCcw } from "@lucide/svelte";
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
spec: FieldSpec;
|
spec: FieldSpec;
|
||||||
toolId: string;
|
toolId: string;
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
|
sourceDims?: Dimension;
|
||||||
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
|
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,8 @@
|
|||||||
schema: ToolSchema<Record<string, unknown>>;
|
schema: ToolSchema<Record<string, unknown>>;
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
toolId: string;
|
toolId: string;
|
||||||
|
/** Размеры входа шага: для maxFromSource-полей (crop и т.п.). */
|
||||||
|
sourceDims?: Dimension;
|
||||||
onchange: (
|
onchange: (
|
||||||
id: string,
|
id: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -61,9 +65,23 @@
|
|||||||
) => void;
|
) => void;
|
||||||
onreset: () => void;
|
onreset: () => void;
|
||||||
}
|
}
|
||||||
let { schema, values, toolId, onchange, onreset }: Props = $props();
|
let {
|
||||||
|
schema,
|
||||||
|
values,
|
||||||
|
toolId,
|
||||||
|
sourceDims = undefined,
|
||||||
|
onchange,
|
||||||
|
onreset,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const layoutGroups = $derived(resolveLayoutGroups(schema));
|
const layoutGroups = $derived(resolveLayoutGroups(schema));
|
||||||
|
|
||||||
|
function effectiveSpec(spec: FieldSpec): FieldSpec {
|
||||||
|
if (sourceDims && spec.kind === "number" && spec.maxFromSource) {
|
||||||
|
return { ...spec, max: effectiveMax(spec, sourceDims) };
|
||||||
|
}
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each layoutGroups as group (group.key)}
|
{#each layoutGroups as group (group.key)}
|
||||||
@@ -81,9 +99,10 @@
|
|||||||
<Control
|
<Control
|
||||||
label={fieldLabel(schema.fields[id], id)}
|
label={fieldLabel(schema.fields[id], id)}
|
||||||
value={values[id]}
|
value={values[id]}
|
||||||
spec={schema.fields[id].spec}
|
spec={effectiveSpec(schema.fields[id].spec)}
|
||||||
{toolId}
|
{toolId}
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
|
{sourceDims}
|
||||||
onchange={(v, axis) => onchange(id, v, axis)}
|
onchange={(v, axis) => onchange(id, v, axis)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MetaList from "$lib/components/display/MetaList.svelte";
|
import { hasPreviewResult } from "./schema-preview-model";
|
||||||
import { buildSchemaPreviewModel } from "./schema-preview-model";
|
|
||||||
import SchemaActions from "./SchemaActions.svelte";
|
import SchemaActions from "./SchemaActions.svelte";
|
||||||
import SchemaResultTile from "./SchemaResultTile.svelte";
|
import SchemaResultTile from "./SchemaResultTile.svelte";
|
||||||
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { OutputMime } from "$lib/core/io";
|
import type { OutputMime } from "$lib/core/io";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
import type {
|
||||||
|
FileResult,
|
||||||
|
InputMode,
|
||||||
|
ResultKind,
|
||||||
|
ResultNote,
|
||||||
|
} from "$lib/registry";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
inputMode: InputMode;
|
inputMode: InputMode;
|
||||||
@@ -15,6 +19,7 @@
|
|||||||
toolId: string;
|
toolId: string;
|
||||||
source: PixelImage | null;
|
source: PixelImage | null;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
|
resultNote?: ResultNote | null;
|
||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textSource?: string;
|
textSource?: string;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
@@ -39,6 +44,7 @@
|
|||||||
toolId,
|
toolId,
|
||||||
source,
|
source,
|
||||||
result,
|
result,
|
||||||
|
resultNote = null,
|
||||||
fileResult = null,
|
fileResult = null,
|
||||||
textSource = "",
|
textSource = "",
|
||||||
textResult = null,
|
textResult = null,
|
||||||
@@ -58,11 +64,8 @@
|
|||||||
onquality,
|
onquality,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const model = $derived(
|
const hasResult = $derived(
|
||||||
buildSchemaPreviewModel(
|
hasPreviewResult({ inputMode, resultKind, result, fileResult, textResult }),
|
||||||
{ inputMode, resultKind, source, result, fileResult, textResult },
|
|
||||||
t,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@
|
|||||||
<SchemaActions
|
<SchemaActions
|
||||||
{inputMode}
|
{inputMode}
|
||||||
{resultKind}
|
{resultKind}
|
||||||
canDownload={model.hasResult}
|
canDownload={hasResult}
|
||||||
{running}
|
{running}
|
||||||
{format}
|
{format}
|
||||||
{quality}
|
{quality}
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
<SchemaResultTile
|
<SchemaResultTile
|
||||||
{resultKind}
|
{resultKind}
|
||||||
{result}
|
{result}
|
||||||
|
{resultNote}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
{textResult}
|
{textResult}
|
||||||
{textVars}
|
{textVars}
|
||||||
@@ -110,15 +114,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
<MetaList
|
|
||||||
items={[
|
|
||||||
{ caption: t("sourceCard.source"), value: model.sourceValue },
|
|
||||||
{ caption: t("resultCard.result"), value: model.resultValue },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.panel-head {
|
.panel-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -145,9 +140,6 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--space-l);
|
gap: var(--space-l);
|
||||||
}
|
}
|
||||||
.meta {
|
|
||||||
margin-top: var(--space-l);
|
|
||||||
}
|
|
||||||
@media (--bp-tablet) {
|
@media (--bp-tablet) {
|
||||||
.pair {
|
.pair {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { toDataUrl } from "$lib/core/io";
|
import { toDataUrl } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, ResultKind } from "$lib/registry";
|
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
|
||||||
import { Check } from "@lucide/svelte";
|
import { Check } from "@lucide/svelte";
|
||||||
import SchemaTextResult from "./SchemaTextResult.svelte";
|
import SchemaTextResult from "./SchemaTextResult.svelte";
|
||||||
import PreviewTile from "./PreviewTile.svelte";
|
import PreviewTile from "./PreviewTile.svelte";
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
resultKind?: ResultKind;
|
resultKind?: ResultKind;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
|
resultNote?: ResultNote | null;
|
||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
let {
|
let {
|
||||||
resultKind = "image",
|
resultKind = "image",
|
||||||
result,
|
result,
|
||||||
|
resultNote = null,
|
||||||
fileResult = null,
|
fileResult = null,
|
||||||
textResult = null,
|
textResult = null,
|
||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
@@ -31,6 +33,11 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const resultUrl = $derived(result ? toDataUrl(result) : null);
|
const resultUrl = $derived(result ? toDataUrl(result) : null);
|
||||||
|
const resultDims = $derived(
|
||||||
|
resultKind === "image" && result
|
||||||
|
? `${result.width} × ${result.height}`
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
const partUrls = $derived(
|
const partUrls = $derived(
|
||||||
resultKind === "files" && fileResult
|
resultKind === "files" && fileResult
|
||||||
? fileResult.files.map((file) => ({
|
? fileResult.files.map((file) => ({
|
||||||
@@ -43,6 +50,12 @@
|
|||||||
let parts = $derived(
|
let parts = $derived(
|
||||||
resultKind === "files" && fileResult ? fileResult.files.length : undefined,
|
resultKind === "files" && fileResult ? fileResult.files.length : undefined,
|
||||||
);
|
);
|
||||||
|
// Размеры одинаковы у всех частей (канвас дополняется) — показываем один раз.
|
||||||
|
let partsDims = $derived(
|
||||||
|
resultKind === "files" && fileResult && fileResult.files.length > 0
|
||||||
|
? `${fileResult.files[0].image.width} × ${fileResult.files[0].image.height}`
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PreviewTile
|
<PreviewTile
|
||||||
@@ -50,8 +63,16 @@
|
|||||||
viewMode={resultKind}
|
viewMode={resultKind}
|
||||||
loading={running}
|
loading={running}
|
||||||
{parts}
|
{parts}
|
||||||
|
{partsDims}
|
||||||
|
dims={resultDims}
|
||||||
|
note={resultNote ? t(resultNote.key) : undefined}
|
||||||
|
noteTone={resultNote?.tone ?? "warning"}
|
||||||
>
|
>
|
||||||
<div class="canvas" class:checker={resultKind === "image"}>
|
<div
|
||||||
|
class="canvas"
|
||||||
|
class:checker={resultKind === "image"}
|
||||||
|
class:files={resultKind === "files"}
|
||||||
|
>
|
||||||
{#if resultKind === "text" && textResult}
|
{#if resultKind === "text" && textResult}
|
||||||
<div class="text-result-wrap">
|
<div class="text-result-wrap">
|
||||||
<SchemaTextResult
|
<SchemaTextResult
|
||||||
@@ -102,6 +123,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* Вложен в PreviewTile.canvas: без растяжения сжимался по контенту и
|
||||||
|
ломал parts-grid в одну колонку. */
|
||||||
|
width: 100%;
|
||||||
|
align-self: stretch;
|
||||||
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
||||||
border: var(--size-border) solid var(--color-border);
|
border: var(--size-border) solid var(--color-border);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
@@ -113,7 +138,7 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 100vh;
|
max-height: 60vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -124,6 +149,11 @@
|
|||||||
)
|
)
|
||||||
50% / 28px 28px;
|
50% / 28px 28px;
|
||||||
}
|
}
|
||||||
|
/* Сетка частей: без потолка 1024 плитки растягивали страницу бесконечно. */
|
||||||
|
.canvas.files {
|
||||||
|
max-height: 60vh;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,32 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
||||||
|
const sourceDims = $derived(
|
||||||
|
source ? `${source.width} × ${source.height}` : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
let dragging = $state(false);
|
let dragging = $state(false);
|
||||||
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
|
// Подсказка замены: показываем на ховер и гасим через 10с до нового захода.
|
||||||
|
let hintVisible = $state(false);
|
||||||
|
let hintTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function showHint() {
|
||||||
|
hintVisible = true;
|
||||||
|
if (hintTimer) clearTimeout(hintTimer);
|
||||||
|
hintTimer = setTimeout(() => {
|
||||||
|
hintVisible = false;
|
||||||
|
hintTimer = null;
|
||||||
|
}, 3_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHint() {
|
||||||
|
hintVisible = false;
|
||||||
|
if (hintTimer) {
|
||||||
|
clearTimeout(hintTimer);
|
||||||
|
hintTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
function onDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -36,9 +60,13 @@
|
|||||||
const file = e.dataTransfer?.files?.[0];
|
const file = e.dataTransfer?.files?.[0];
|
||||||
if (file) onupload?.(file);
|
if (file) onupload?.(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
input?.click();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PreviewTile label={t("sourceCard.source")} viewMode={mode}>
|
<PreviewTile label={t("sourceCard.source")} viewMode={mode} dims={sourceDims}>
|
||||||
{#if mode === "text"}
|
{#if mode === "text"}
|
||||||
<div class="text-source-wrap">
|
<div class="text-source-wrap">
|
||||||
<SchemaTextSource
|
<SchemaTextSource
|
||||||
@@ -52,15 +80,38 @@
|
|||||||
<div
|
<div
|
||||||
class="tile-drop"
|
class="tile-drop"
|
||||||
class:dragging
|
class:dragging
|
||||||
role="region"
|
class:hint={hintVisible}
|
||||||
aria-label={t("dropZone.overlayDefault")}
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label={t("sourceCard.replaceImage")}
|
||||||
|
onclick={open}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragging = true;
|
dragging = true;
|
||||||
}}
|
}}
|
||||||
ondragleave={() => (dragging = false)}
|
ondragleave={() => (dragging = false)}
|
||||||
ondrop={onDrop}
|
ondrop={onDrop}
|
||||||
|
onmouseenter={showHint}
|
||||||
|
onmouseleave={hideHint}
|
||||||
|
onfocus={showHint}
|
||||||
|
onblur={hideHint}
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onchange={(e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) onupload?.(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<img
|
<img
|
||||||
data-testid="source-image"
|
data-testid="source-image"
|
||||||
src={sourceUrl}
|
src={sourceUrl}
|
||||||
@@ -68,6 +119,8 @@
|
|||||||
/>
|
/>
|
||||||
{#if dragging}
|
{#if dragging}
|
||||||
<span class="drop-hint">{t("dropZone.overlayDefault")}</span>
|
<span class="drop-hint">{t("dropZone.overlayDefault")}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="hover-hint">{t("sourceCard.replaceHint")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if mode === "image"}
|
{:else if mode === "image"}
|
||||||
@@ -114,4 +167,38 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.tile-drop {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tile-drop::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-scrim);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-s) ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.tile-drop.hint::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.tile-drop .hover-hint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 25%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--space-l);
|
||||||
|
border: var(--size-border-thick) dashed var(--color-main);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-panel);
|
||||||
|
color: var(--color-main);
|
||||||
|
font: 600 var(--font-size-l) var(--font-mono);
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-s) ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.tile-drop.hint .hover-hint {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
} from "$lib/registry";
|
} from "$lib/registry";
|
||||||
import {
|
import {
|
||||||
applySourceDefaults,
|
applySourceDefaults,
|
||||||
|
clampSourceAwareMaxes,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
withAspectLock,
|
withAspectLock,
|
||||||
type Dimension,
|
type Dimension,
|
||||||
@@ -105,6 +106,11 @@
|
|||||||
// он — единый источник качества; dropdown редактирует его же.
|
// он — единый источник качества; dropdown редактирует его же.
|
||||||
const qualityParamId = $derived(lastTool.output?.qualityParamId);
|
const qualityParamId = $derived(lastTool.output?.qualityParamId);
|
||||||
const lastParams = $derived(steps.at(-1)?.params);
|
const lastParams = $derived(steps.at(-1)?.params);
|
||||||
|
const resultNote = $derived(
|
||||||
|
result && lastTool.resultNote
|
||||||
|
? lastTool.resultNote(lastParams ?? {}, result)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
const currentQuality = $derived.by(() => {
|
const currentQuality = $derived.by(() => {
|
||||||
const setting = outputFormatByMime(format).settings?.quality;
|
const setting = outputFormatByMime(format).settings?.quality;
|
||||||
if (!setting) return undefined;
|
if (!setting) return undefined;
|
||||||
@@ -205,7 +211,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps = steps.with(index, { ...step, params: next });
|
const clamped = dims ? clampSourceAwareMaxes(stepSchema, next, dims) : next;
|
||||||
|
steps = steps.with(index, { ...step, params: clamped });
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetStep(index: number) {
|
function resetStep(index: number) {
|
||||||
@@ -249,11 +256,15 @@
|
|||||||
if (first && firstSchema) {
|
if (first && firstSchema) {
|
||||||
steps = steps.with(0, {
|
steps = steps.with(0, {
|
||||||
...first,
|
...first,
|
||||||
params: applySourceDefaults(
|
params: clampSourceAwareMaxes(
|
||||||
firstSchema,
|
firstSchema,
|
||||||
first.params,
|
applySourceDefaults(
|
||||||
{ source: decoded },
|
firstSchema,
|
||||||
touchedFor(first.key),
|
first.params,
|
||||||
|
{ source: decoded },
|
||||||
|
touchedFor(first.key),
|
||||||
|
),
|
||||||
|
decoded,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -309,7 +320,7 @@
|
|||||||
const base = toDisplayError(e);
|
const base = toDisplayError(e);
|
||||||
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
|
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t("chain.stepError", {
|
t("toolPage.stepError", {
|
||||||
n: i + 1,
|
n: i + 1,
|
||||||
title: stepTitle(stepTool.id),
|
title: stepTitle(stepTool.id),
|
||||||
msg,
|
msg,
|
||||||
@@ -468,6 +479,7 @@
|
|||||||
schema={toolSchemaOf(step)}
|
schema={toolSchemaOf(step)}
|
||||||
values={step.params}
|
values={step.params}
|
||||||
toolId={step.id}
|
toolId={step.id}
|
||||||
|
sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]}
|
||||||
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
|
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
|
||||||
onreset={() => resetStep(i)}
|
onreset={() => resetStep(i)}
|
||||||
/>
|
/>
|
||||||
@@ -483,6 +495,7 @@
|
|||||||
toolId={lastTool.id}
|
toolId={lastTool.id}
|
||||||
{source}
|
{source}
|
||||||
{result}
|
{result}
|
||||||
|
{resultNote}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
{textSource}
|
{textSource}
|
||||||
{textResult}
|
{textResult}
|
||||||
|
|||||||
@@ -1,107 +1,47 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { makeImage } from "../../core/test-helpers";
|
|
||||||
import {
|
import {
|
||||||
buildSchemaPreviewModel,
|
hasPreviewResult,
|
||||||
type PreviewTranslate,
|
type SchemaPreviewModelInput,
|
||||||
} from "./schema-preview-model";
|
} from "./schema-preview-model";
|
||||||
|
|
||||||
const image = makeImage(2, 3, [
|
const image = { width: 2, height: 3, data: new Uint8ClampedArray(24) };
|
||||||
[1, 2, 3, 255],
|
|
||||||
[4, 5, 6, 255],
|
|
||||||
[7, 8, 9, 255],
|
|
||||||
[10, 11, 12, 255],
|
|
||||||
[13, 14, 15, 255],
|
|
||||||
[16, 17, 18, 255],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const translate: PreviewTranslate = (key) => {
|
function input(over: Partial<SchemaPreviewModelInput> = {}) {
|
||||||
if (key === "resultCard.parts") return "parts";
|
|
||||||
if (key === "textInput.heading") return "Text";
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
function input(
|
|
||||||
overrides: Partial<Parameters<typeof buildSchemaPreviewModel>[0]> = {},
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
inputMode: "image" as const,
|
inputMode: "image",
|
||||||
resultKind: "image" as const,
|
resultKind: "image",
|
||||||
source: null,
|
|
||||||
result: null,
|
result: null,
|
||||||
fileResult: null,
|
fileResult: null,
|
||||||
textResult: null,
|
textResult: null,
|
||||||
...overrides,
|
...over,
|
||||||
};
|
} satisfies SchemaPreviewModelInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("buildSchemaPreviewModel", () => {
|
describe("hasPreviewResult", () => {
|
||||||
it("formats source values for every input mode", () => {
|
it("image: результат только при наличии картинки", () => {
|
||||||
expect(
|
expect(hasPreviewResult(input({ result: image }))).toBe(true);
|
||||||
buildSchemaPreviewModel(input({ inputMode: "none" }), translate),
|
expect(hasPreviewResult(input())).toBe(false);
|
||||||
).toMatchObject({ sourceValue: "—" });
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(input({ inputMode: "text" }), translate),
|
|
||||||
).toMatchObject({ sourceValue: "Text" });
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(
|
|
||||||
input({ inputMode: "image", source: image }),
|
|
||||||
translate,
|
|
||||||
),
|
|
||||||
).toMatchObject({ sourceValue: "2 × 3 px" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats image results and enables download only with a result", () => {
|
it("files: результат только при непустом наборе", () => {
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(input({ result: image }), translate),
|
|
||||||
).toEqual({
|
|
||||||
sourceValue: "—",
|
|
||||||
resultValue: "2 × 3 px",
|
|
||||||
hasResult: true,
|
|
||||||
});
|
|
||||||
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
|
|
||||||
resultValue: "—",
|
|
||||||
hasResult: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats file results as ZIP and rejects empty file results", () => {
|
|
||||||
const fileResult = { files: [{ name: "part.png", image }] };
|
const fileResult = { files: [{ name: "part.png", image }] };
|
||||||
|
expect(hasPreviewResult(input({ resultKind: "files", fileResult }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
buildSchemaPreviewModel(
|
hasPreviewResult(
|
||||||
input({ resultKind: "files", fileResult }),
|
|
||||||
translate,
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
sourceValue: "—",
|
|
||||||
resultValue: "1 parts",
|
|
||||||
hasResult: true,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(
|
|
||||||
input({ resultKind: "files", fileResult: { files: [] } }),
|
input({ resultKind: "files", fileResult: { files: [] } }),
|
||||||
translate,
|
|
||||||
),
|
),
|
||||||
).toMatchObject({ resultValue: "—", hasResult: false });
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["text", "verdict"] as const)(
|
it.each(["text", "verdict"] as const)(
|
||||||
"uses text result presence for %s output",
|
"%s: результат по наличию текста",
|
||||||
(resultKind) => {
|
(resultKind) => {
|
||||||
expect(
|
expect(hasPreviewResult(input({ resultKind, textResult: "done" }))).toBe(
|
||||||
buildSchemaPreviewModel(
|
true,
|
||||||
input({ resultKind, textResult: "done" }),
|
);
|
||||||
translate,
|
expect(hasPreviewResult(input({ resultKind }))).toBe(false);
|
||||||
),
|
|
||||||
).toMatchObject({
|
|
||||||
resultValue: "Text",
|
|
||||||
hasResult: true,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(input({ resultKind }), translate),
|
|
||||||
).toMatchObject({
|
|
||||||
resultValue: "—",
|
|
||||||
hasResult: false,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,58 +1,20 @@
|
|||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
||||||
|
|
||||||
export type PreviewTranslate = (key: string) => string;
|
|
||||||
|
|
||||||
export type SchemaPreviewModelInput = {
|
export type SchemaPreviewModelInput = {
|
||||||
inputMode: InputMode;
|
inputMode: InputMode;
|
||||||
resultKind: ResultKind;
|
resultKind: ResultKind;
|
||||||
source: PixelImage | null;
|
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
fileResult: FileResult | null;
|
fileResult: FileResult | null;
|
||||||
textResult: string | null;
|
textResult: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SchemaPreviewModel = {
|
/** Можно ли скачать результат: он есть и он не пустой. */
|
||||||
sourceValue: string;
|
export function hasPreviewResult(input: SchemaPreviewModelInput): boolean {
|
||||||
resultValue: string;
|
const { resultKind, result, fileResult, textResult } = input;
|
||||||
hasResult: boolean;
|
if (resultKind === "image") return Boolean(result);
|
||||||
};
|
if (resultKind === "files") {
|
||||||
|
return Boolean(fileResult && fileResult.files.length > 0);
|
||||||
export function buildSchemaPreviewModel(
|
}
|
||||||
input: SchemaPreviewModelInput,
|
return Boolean(textResult);
|
||||||
translate: PreviewTranslate,
|
|
||||||
): SchemaPreviewModel {
|
|
||||||
const { inputMode, resultKind, source, result, fileResult, textResult } =
|
|
||||||
input;
|
|
||||||
const sourceValue =
|
|
||||||
inputMode === "none"
|
|
||||||
? "—"
|
|
||||||
: inputMode === "text"
|
|
||||||
? translate("textInput.heading")
|
|
||||||
: source
|
|
||||||
? `${source.width} × ${source.height} px`
|
|
||||||
: "—";
|
|
||||||
const hasFiles = Boolean(fileResult && fileResult.files.length > 0);
|
|
||||||
const resultValue =
|
|
||||||
resultKind === "image"
|
|
||||||
? result
|
|
||||||
? `${result.width} × ${result.height} px`
|
|
||||||
: "—"
|
|
||||||
: resultKind === "files"
|
|
||||||
? hasFiles
|
|
||||||
? `${fileResult?.files.length} ${translate("resultCard.parts")}`
|
|
||||||
: "—"
|
|
||||||
: textResult
|
|
||||||
? translate("textInput.heading")
|
|
||||||
: "—";
|
|
||||||
return {
|
|
||||||
sourceValue,
|
|
||||||
resultValue,
|
|
||||||
hasResult:
|
|
||||||
resultKind === "image"
|
|
||||||
? Boolean(result)
|
|
||||||
: resultKind === "files"
|
|
||||||
? hasFiles
|
|
||||||
: Boolean(textResult),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
line-height: normal;
|
line-height: 1;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export interface TextToImageOptions {
|
|||||||
bold: boolean;
|
bold: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
transparentBg: boolean;
|
bgOpacity: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
maxTextWidth?: number;
|
maxTextWidth?: number;
|
||||||
}
|
}
|
||||||
@@ -148,9 +148,11 @@ export function renderTextToImage(o: TextToImageOptions): PixelImage {
|
|||||||
const h = Math.max(1, Math.ceil(lineH + o.padding * 2));
|
const h = Math.max(1, Math.ceil(lineH + o.padding * 2));
|
||||||
const { canvas, ctx } = ctx2d(w, h);
|
const { canvas, ctx } = ctx2d(w, h);
|
||||||
|
|
||||||
if (!o.transparentBg) {
|
if (o.bgOpacity > 0) {
|
||||||
|
ctx.globalAlpha = Math.min(100, Math.max(0, o.bgOpacity)) / 100;
|
||||||
ctx.fillStyle = o.backgroundColor;
|
ctx.fillStyle = o.backgroundColor;
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
ctx.font = fontString(o.fontSize, o.font, o.bold);
|
||||||
ctx.fillStyle = o.color;
|
ctx.fillStyle = o.color;
|
||||||
|
|||||||
@@ -29,16 +29,22 @@ describe("randomColorBlocks", () => {
|
|||||||
|
|
||||||
describe("drawGrid", () => {
|
describe("drawGrid", () => {
|
||||||
it("линии на пересечениях непрозрачны, фон прозрачен", () => {
|
it("линии на пересечениях непрозрачны, фон прозрачен", () => {
|
||||||
const out = drawGrid(100, 100, 4, 4, 2, "#000000", true);
|
const out = drawGrid(100, 100, 4, 4, 2, "#000000", 0);
|
||||||
expect(out.data[0]).toBe(0);
|
expect(out.data[0]).toBe(0);
|
||||||
expect(out.data[3]).toBe(255); // (0,0) на линии
|
expect(out.data[3]).toBe(255); // (0,0) на линии
|
||||||
const mid = (53 * 100 + 53) * 4;
|
const mid = (53 * 100 + 53) * 4;
|
||||||
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн
|
expect(out.data[mid + 3]).toBe(0); // между линиями прозрачн
|
||||||
});
|
});
|
||||||
|
|
||||||
it("белый непрозрачный фон при transparentBg=false", () => {
|
it("белый непрозрачный фон при bgOpacity=100", () => {
|
||||||
const out = drawGrid(20, 20, 2, 2, 1, "#000000", false);
|
const out = drawGrid(20, 20, 2, 2, 1, "#000000", 100);
|
||||||
const mid = (5 * 20 + 5) * 4;
|
const mid = (5 * 20 + 5) * 4;
|
||||||
expect(out.data[mid]).toBe(255);
|
expect(out.data[mid]).toBe(255);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("полупрозрачный фон при bgOpacity=50", () => {
|
||||||
|
const out = drawGrid(20, 20, 2, 2, 1, "#000000", 50);
|
||||||
|
const mid = (5 * 20 + 5) * 4;
|
||||||
|
expect(out.data[mid + 3]).toBe(128);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function lineMask(
|
|||||||
return mask;
|
return mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сетка линий на прозрачном или белом фоне. */
|
/** Сетка линий на белом фоне заданной непрозрачности (0 — прозрачный). */
|
||||||
export function drawGrid(
|
export function drawGrid(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
@@ -87,10 +87,20 @@ export function drawGrid(
|
|||||||
rows: number,
|
rows: number,
|
||||||
lineWidth: number,
|
lineWidth: number,
|
||||||
colorHex: string,
|
colorHex: string,
|
||||||
transparentBg: boolean,
|
bgOpacity: number,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(width, height);
|
const out = createPixelImage(width, height);
|
||||||
if (!transparentBg) out.data.fill(255);
|
const bgAlpha = Math.round(
|
||||||
|
(Math.max(0, Math.min(100, bgOpacity)) / 100) * 255,
|
||||||
|
);
|
||||||
|
if (bgAlpha > 0) {
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
out.data[i] = 255;
|
||||||
|
out.data[i + 1] = 255;
|
||||||
|
out.data[i + 2] = 255;
|
||||||
|
out.data[i + 3] = bgAlpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const m = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
const m = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
||||||
const r = m ? parseInt(m[1].slice(0, 2), 16) : 0;
|
const r = m ? parseInt(m[1].slice(0, 2), 16) : 0;
|
||||||
|
|||||||
@@ -9,19 +9,20 @@ export function expandCanvas(
|
|||||||
right: number,
|
right: number,
|
||||||
bottom: number,
|
bottom: number,
|
||||||
backgroundHex?: string,
|
backgroundHex?: string,
|
||||||
|
backgroundAlpha = 255,
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const l = Math.max(0, Math.trunc(left));
|
const l = Math.max(0, Math.trunc(left));
|
||||||
const t = Math.max(0, Math.trunc(top));
|
const t = Math.max(0, Math.trunc(top));
|
||||||
const r = Math.max(0, Math.trunc(right));
|
const r = Math.max(0, Math.trunc(right));
|
||||||
const b = Math.max(0, Math.trunc(bottom));
|
const b = Math.max(0, Math.trunc(bottom));
|
||||||
const out = createPixelImage(img.width + l + r, img.height + t + b);
|
const out = createPixelImage(img.width + l + r, img.height + t + b);
|
||||||
if (backgroundHex !== undefined) {
|
if (backgroundHex !== undefined && backgroundAlpha > 0) {
|
||||||
const [cr, cg, cb] = parseHex(backgroundHex);
|
const [cr, cg, cb] = parseHex(backgroundHex);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
out.data[i] = cr;
|
out.data[i] = cr;
|
||||||
out.data[i + 1] = cg;
|
out.data[i + 1] = cg;
|
||||||
out.data[i + 2] = cb;
|
out.data[i + 2] = cb;
|
||||||
out.data[i + 3] = 255;
|
out.data[i + 3] = backgroundAlpha;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let y = 0; y < img.height; y++) {
|
for (let y = 0; y < img.height; y++) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const en: Dict = {
|
|||||||
sourceCard: {
|
sourceCard: {
|
||||||
source: "Source",
|
source: "Source",
|
||||||
replaceImage: "Replace image",
|
replaceImage: "Replace image",
|
||||||
|
replaceHint: "Click to upload an image",
|
||||||
chooseImage: "choose an image",
|
chooseImage: "choose an image",
|
||||||
noSource: "no source — configure the parameters",
|
noSource: "no source — configure the parameters",
|
||||||
alt: "Source image",
|
alt: "Source image",
|
||||||
@@ -75,6 +76,9 @@ export const en: Dict = {
|
|||||||
result: "Result",
|
result: "Result",
|
||||||
previewPanel: "Preview",
|
previewPanel: "Preview",
|
||||||
parts: "parts",
|
parts: "parts",
|
||||||
|
clamped: "clamped to image bounds",
|
||||||
|
aspectAdjusted: "size adjusted to keep aspect",
|
||||||
|
grown: "grew beyond requested size — image is larger",
|
||||||
alt: "Result image",
|
alt: "Result image",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
@@ -173,7 +177,8 @@ export const en: Dict = {
|
|||||||
radiusInt: "Radius must be a non-negative integer",
|
radiusInt: "Radius must be a non-negative integer",
|
||||||
kernelSize: "Kernel does not match the image dimensions",
|
kernelSize: "Kernel does not match the image dimensions",
|
||||||
sizeInt: "Width and height must be integers ≥ 1",
|
sizeInt: "Width and height must be integers ≥ 1",
|
||||||
cropBounds: "Crop area does not intersect the image",
|
cropBounds:
|
||||||
|
"Crop area doesn't fit: offset plus size exceeds the image bounds",
|
||||||
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
|
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
|
||||||
badBase64: "Expected a base64 string or a data-uri of an image",
|
badBase64: "Expected a base64 string or a data-uri of an image",
|
||||||
qualityRange: "quality must be within 0..1",
|
qualityRange: "quality must be within 0..1",
|
||||||
@@ -196,9 +201,9 @@ export const en: Dict = {
|
|||||||
paramString: 'Parameter "{id}" must be a string',
|
paramString: 'Parameter "{id}" must be a string',
|
||||||
paramBool: 'Parameter "{id}" must be a checkbox value',
|
paramBool: 'Parameter "{id}" must be a checkbox value',
|
||||||
resizeSize: "Width and/or height must be positive",
|
resizeSize: "Width and/or height must be positive",
|
||||||
cropSize: "Crop width and height must be positive",
|
cropSize: "Crop width and height must be at least 1 px",
|
||||||
sizePositive: "Dimensions must be positive and finite",
|
sizePositive: "Dimensions must be positive and finite",
|
||||||
tooManyParts: "Too many parts ({count}). Maximum is 1000.",
|
tooManyParts: "Too many parts ({count}). The limit is {max}.",
|
||||||
pageUnknown: 'No page is registered under "{slug}".',
|
pageUnknown: 'No page is registered under "{slug}".',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
@@ -297,8 +302,6 @@ export const en: Dict = {
|
|||||||
textStyle: "Text style",
|
textStyle: "Text style",
|
||||||
thickness: "Thickness",
|
thickness: "Thickness",
|
||||||
threshold: "Threshold",
|
threshold: "Threshold",
|
||||||
transparent: "Transparent",
|
|
||||||
transparentBg: "Transparent bg",
|
|
||||||
waves: "Waves",
|
waves: "Waves",
|
||||||
width: "Width",
|
width: "Width",
|
||||||
x: "X",
|
x: "X",
|
||||||
|
|||||||
+13
-10
@@ -60,6 +60,7 @@ export const ru: Dict = {
|
|||||||
sourceCard: {
|
sourceCard: {
|
||||||
source: "Источник",
|
source: "Источник",
|
||||||
replaceImage: "Заменить изображение",
|
replaceImage: "Заменить изображение",
|
||||||
|
replaceHint: "Кликните, чтобы загрузить изображение",
|
||||||
chooseImage: "выберите изображение",
|
chooseImage: "выберите изображение",
|
||||||
noSource: "нет источника — настройте параметры",
|
noSource: "нет источника — настройте параметры",
|
||||||
alt: "Исходное изображение",
|
alt: "Исходное изображение",
|
||||||
@@ -76,6 +77,9 @@ export const ru: Dict = {
|
|||||||
result: "Результат",
|
result: "Результат",
|
||||||
previewPanel: "Предпросмотр",
|
previewPanel: "Предпросмотр",
|
||||||
parts: "частей",
|
parts: "частей",
|
||||||
|
clamped: "уменьшено до границ изображения",
|
||||||
|
aspectAdjusted: "размер скорректирован по пропорциям",
|
||||||
|
grown: "больше запрошенного — картинка крупнее",
|
||||||
alt: "Изображение-результат",
|
alt: "Изображение-результат",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
@@ -174,7 +178,8 @@ export const ru: Dict = {
|
|||||||
radiusInt: "Радиус должен быть целым неотрицательным числом",
|
radiusInt: "Радиус должен быть целым неотрицательным числом",
|
||||||
kernelSize: "Ядро не совпадает с изображением по размеру",
|
kernelSize: "Ядро не совпадает с изображением по размеру",
|
||||||
sizeInt: "Ширина и высота должны быть целыми числами ≥ 1",
|
sizeInt: "Ширина и высота должны быть целыми числами ≥ 1",
|
||||||
cropBounds: "Область обрезки не пересекает изображение",
|
cropBounds:
|
||||||
|
"Область не помещается: смещение + размер превышают границы изображения",
|
||||||
noCanvasCtx: "Canvas 2D context недоступен в этом окружении",
|
noCanvasCtx: "Canvas 2D context недоступен в этом окружении",
|
||||||
badBase64: "Ожидается base64-строка или data-uri изображения",
|
badBase64: "Ожидается base64-строка или data-uri изображения",
|
||||||
qualityRange: "quality должно быть в диапазоне 0..1",
|
qualityRange: "quality должно быть в диапазоне 0..1",
|
||||||
@@ -197,9 +202,9 @@ export const ru: Dict = {
|
|||||||
paramString: 'Параметр "{id}" должен быть строкой',
|
paramString: 'Параметр "{id}" должен быть строкой',
|
||||||
paramBool: 'Параметр "{id}" должен быть значением флажка',
|
paramBool: 'Параметр "{id}" должен быть значением флажка',
|
||||||
resizeSize: "Ширина и/или высота должны быть положительными",
|
resizeSize: "Ширина и/или высота должны быть положительными",
|
||||||
cropSize: "Ширина и высота области обрезки должны быть положительными",
|
cropSize: "Ширина и высота области должны быть не меньше 1 px",
|
||||||
sizePositive: "Размеры должны быть положительными и конечными",
|
sizePositive: "Размеры должны быть положительными и конечными",
|
||||||
tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.",
|
tooManyParts: "Слишком много частей ({count}). Ограничение — {max}.",
|
||||||
pageUnknown: "Страницы «{slug}» нет в каталоге.",
|
pageUnknown: "Страницы «{slug}» нет в каталоге.",
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
@@ -298,8 +303,6 @@ export const ru: Dict = {
|
|||||||
textStyle: "Стиль текста",
|
textStyle: "Стиль текста",
|
||||||
thickness: "Толщина",
|
thickness: "Толщина",
|
||||||
threshold: "Порог",
|
threshold: "Порог",
|
||||||
transparent: "Прозрачный",
|
|
||||||
transparentBg: "Прозрачный фон",
|
|
||||||
waves: "Волны",
|
waves: "Волны",
|
||||||
width: "Ширина",
|
width: "Ширина",
|
||||||
x: "X",
|
x: "X",
|
||||||
@@ -971,7 +974,7 @@ export const ru: Dict = {
|
|||||||
"add-padding": {
|
"add-padding": {
|
||||||
params: {
|
params: {
|
||||||
padding: "Поля, px",
|
padding: "Поля, px",
|
||||||
transparent: "Прозрачные поля",
|
opacity: "Непрозрачность полей, %",
|
||||||
color: "Цвет полей",
|
color: "Цвет полей",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -982,7 +985,7 @@ export const ru: Dict = {
|
|||||||
params: {
|
params: {
|
||||||
width: "Ширина полотна",
|
width: "Ширина полотна",
|
||||||
height: "Высота полотна",
|
height: "Высота полотна",
|
||||||
transparent: "Прозрачный фон",
|
opacity: "Непрозрачность фона, %",
|
||||||
color: "Цвет фона",
|
color: "Цвет фона",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1112,7 +1115,7 @@ export const ru: Dict = {
|
|||||||
params: {
|
params: {
|
||||||
width: "Ширина",
|
width: "Ширина",
|
||||||
height: "Высота",
|
height: "Высота",
|
||||||
transparent: "Прозрачный",
|
opacity: "Непрозрачность",
|
||||||
color: "Цвет",
|
color: "Цвет",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1351,7 +1354,7 @@ export const ru: Dict = {
|
|||||||
font: "Шрифт",
|
font: "Шрифт",
|
||||||
bold: "Жирный",
|
bold: "Жирный",
|
||||||
color: "Цвет текста",
|
color: "Цвет текста",
|
||||||
transparentBg: "Прозрачный фон",
|
bgOpacity: "Непрозрачность фона, %",
|
||||||
backgroundColor: "Цвет фона",
|
backgroundColor: "Цвет фона",
|
||||||
padding: "Отступ, px",
|
padding: "Отступ, px",
|
||||||
},
|
},
|
||||||
@@ -1396,7 +1399,7 @@ export const ru: Dict = {
|
|||||||
rows: "Строки",
|
rows: "Строки",
|
||||||
lineWidth: "Толщина линий, px",
|
lineWidth: "Толщина линий, px",
|
||||||
color: "Цвет линий",
|
color: "Цвет линий",
|
||||||
transparentBg: "Прозрачный фон",
|
bgOpacity: "Непрозрачность фона, %",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
quantize: {
|
quantize: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
applySourceDefaults,
|
applySourceDefaults,
|
||||||
|
clampSourceAwareMaxes,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
field,
|
field,
|
||||||
resolveLayoutGroups,
|
resolveLayoutGroups,
|
||||||
@@ -89,6 +90,107 @@ describe("registry-schema: withAspectLock", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("registry-schema: maxFromSource", () => {
|
||||||
|
const cropLike = toolSchema<{
|
||||||
|
x: number;
|
||||||
|
size: Dimension;
|
||||||
|
}>({
|
||||||
|
x: field.number({ min: 0, max: 20000, default: 0, maxFromSource: "width" }),
|
||||||
|
size: field.dimension({
|
||||||
|
min: 1,
|
||||||
|
max: 20000,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
maxFromSource: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const img = { width: 832, height: 1216 };
|
||||||
|
|
||||||
|
it("number клампится к размеру исходника, а не к статическому max", () => {
|
||||||
|
const s = sanitizeSchemaParams(cropLike, { x: 5000 }, { source: img });
|
||||||
|
expect(s.x).toBe(832);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dimension клампится по осям исходника", () => {
|
||||||
|
const s = sanitizeSchemaParams(
|
||||||
|
cropLike,
|
||||||
|
{ size: { width: 5000, height: 5000 } },
|
||||||
|
{ source: img },
|
||||||
|
);
|
||||||
|
expect(s.size).toEqual({ width: 832, height: 1216 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без исходника — статический max", () => {
|
||||||
|
const s = sanitizeSchemaParams(cropLike, {
|
||||||
|
x: 5000,
|
||||||
|
size: { width: 5000, height: 5000 },
|
||||||
|
});
|
||||||
|
expect(s.x).toBe(5000);
|
||||||
|
expect(s.size).toEqual({ width: 5000, height: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxMinus: потолок = исходник − константа (offset до size−1)", () => {
|
||||||
|
const offsetLike = toolSchema<{ x: number }>({
|
||||||
|
x: field.number({
|
||||||
|
min: 0,
|
||||||
|
max: 20000,
|
||||||
|
default: 0,
|
||||||
|
maxFromSource: "width",
|
||||||
|
maxMinus: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const s = sanitizeSchemaParams(
|
||||||
|
offsetLike,
|
||||||
|
{ x: 5000 },
|
||||||
|
{ source: { width: 832, height: 1216 } },
|
||||||
|
);
|
||||||
|
expect(s.x).toBe(831);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("effectiveMax: без maxFromSource и без исходника — статический max", () => {
|
||||||
|
const plain = toolSchema<{ a: number; b: number }>({
|
||||||
|
a: field.number({ min: 0, max: 100, default: 0 }),
|
||||||
|
b: field.number({
|
||||||
|
min: 0,
|
||||||
|
max: 20000,
|
||||||
|
default: 0,
|
||||||
|
maxFromSource: "width",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const s = sanitizeSchemaParams(
|
||||||
|
plain,
|
||||||
|
{ a: 500, b: 5000 },
|
||||||
|
{ source: { width: 832, height: 1216 } },
|
||||||
|
);
|
||||||
|
expect(s.a).toBe(100);
|
||||||
|
const noSource = sanitizeSchemaParams(plain, { b: 5000 });
|
||||||
|
expect(noSource.b).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clampSourceAwareMaxes: без исходника и без изменений — те же params", () => {
|
||||||
|
const noSource = clampSourceAwareMaxes(cropLike, { x: 5000 });
|
||||||
|
expect(noSource).toEqual({ x: 5000 });
|
||||||
|
const untouched = clampSourceAwareMaxes(
|
||||||
|
cropLike,
|
||||||
|
{ x: "мусор", size: { width: 100, height: 100 } },
|
||||||
|
img,
|
||||||
|
);
|
||||||
|
expect(untouched).toEqual({
|
||||||
|
x: "мусор",
|
||||||
|
size: { width: 100, height: 100 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clampSourceAwareMaxes: dimension с мусором пропускается", () => {
|
||||||
|
const out = clampSourceAwareMaxes(
|
||||||
|
cropLike,
|
||||||
|
{ size: "не-объект" as unknown as Dimension },
|
||||||
|
img,
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ size: "не-объект" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("registry-schema: source-aware dimension defaults", () => {
|
describe("registry-schema: source-aware dimension defaults", () => {
|
||||||
it("uses static fallback before a source exists", () => {
|
it("uses static fallback before a source exists", () => {
|
||||||
expect(defaultSchemaParams(sourceSchema)).toEqual({
|
expect(defaultSchemaParams(sourceSchema)).toEqual({
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface NumberSpec extends FieldSpecBase {
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
|
/** Потолок из размеров исходника (ось); статический max — фолбэк. */
|
||||||
|
maxFromSource?: "width" | "height";
|
||||||
|
maxMinus?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SliderSpec extends FieldSpecBase {
|
export interface SliderSpec extends FieldSpecBase {
|
||||||
@@ -73,10 +76,9 @@ export interface DimensionSpec extends FieldSpecBase {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
defaultFromSource?: boolean;
|
defaultFromSource?: boolean;
|
||||||
/**
|
/** Потолок каждой оси из соответствующего размера исходника. */
|
||||||
* Id checkbox-поля «сохранять пропорции»: пока оно включено, правка одной
|
maxFromSource?: boolean;
|
||||||
* оси пересчитывает вторую по аспекту текущего исходника.
|
/** Id checkbox-поля «сохранять пропорции». */
|
||||||
*/
|
|
||||||
lockAspectWith?: string;
|
lockAspectWith?: string;
|
||||||
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
|
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
|
||||||
presets?: boolean;
|
presets?: boolean;
|
||||||
@@ -286,6 +288,7 @@ export const field = {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
defaultFromSource?: boolean;
|
defaultFromSource?: boolean;
|
||||||
|
maxFromSource?: boolean;
|
||||||
lockAspectWith?: string;
|
lockAspectWith?: string;
|
||||||
presets?: boolean;
|
presets?: boolean;
|
||||||
}): Field<Dimension> => ({
|
}): Field<Dimension> => ({
|
||||||
@@ -394,6 +397,68 @@ function clamp(value: number, min?: number, max?: number): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Потолок поля: из размеров исходника при maxFromSource, иначе статический. */
|
||||||
|
export function effectiveMax(
|
||||||
|
spec: NumberSpec,
|
||||||
|
source?: Dimension,
|
||||||
|
): number | undefined {
|
||||||
|
if (spec.maxFromSource && source) {
|
||||||
|
return source[spec.maxFromSource] - (spec.maxMinus ?? 0);
|
||||||
|
}
|
||||||
|
return spec.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimensionAxisMax(
|
||||||
|
spec: DimensionSpec,
|
||||||
|
axis: "width" | "height",
|
||||||
|
source?: Dimension,
|
||||||
|
): number {
|
||||||
|
return spec.maxFromSource && source ? source[axis] : spec.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поджимает maxFromSource-поля к актуальному потолку (сменился исходник). */
|
||||||
|
export function clampSourceAwareMaxes<P>(
|
||||||
|
schema: ToolSchema<P>,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
source?: Dimension,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!source) return params;
|
||||||
|
let changed = false;
|
||||||
|
const out = { ...params };
|
||||||
|
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||||
|
const spec = schema.fields[key].spec;
|
||||||
|
if (spec.kind === "number" && spec.maxFromSource) {
|
||||||
|
const v = out[key as string];
|
||||||
|
if (typeof v !== "number" || !Number.isFinite(v)) continue;
|
||||||
|
const clamped = clamp(v, spec.min, effectiveMax(spec, source));
|
||||||
|
if (clamped !== v) {
|
||||||
|
out[key as string] = clamped;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (spec.kind === "dimension" && spec.maxFromSource) {
|
||||||
|
const v = out[key as string] as Partial<Dimension> | undefined;
|
||||||
|
if (typeof v?.width !== "number" || typeof v?.height !== "number") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const width = clamp(
|
||||||
|
v.width,
|
||||||
|
spec.min,
|
||||||
|
dimensionAxisMax(spec, "width", source),
|
||||||
|
);
|
||||||
|
const height = clamp(
|
||||||
|
v.height,
|
||||||
|
spec.min,
|
||||||
|
dimensionAxisMax(spec, "height", source),
|
||||||
|
);
|
||||||
|
if (width !== v.width || height !== v.height) {
|
||||||
|
out[key as string] = { width, height };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? out : params;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пересчёт при включённом lockAspect: ведущая ось (последняя правка
|
* Пересчёт при включённом lockAspect: ведущая ось (последняя правка
|
||||||
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
|
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
|
||||||
@@ -500,7 +565,11 @@ export function sanitizeSchemaParams<P>(
|
|||||||
case "slider": {
|
case "slider": {
|
||||||
const n =
|
const n =
|
||||||
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
|
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
|
||||||
out[key] = clamp(n, spec.min, spec.max);
|
const max =
|
||||||
|
spec.kind === "number"
|
||||||
|
? effectiveMax(spec, context?.source)
|
||||||
|
: spec.max;
|
||||||
|
out[key] = clamp(n, spec.min, max);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "select":
|
case "select":
|
||||||
@@ -543,8 +612,16 @@ export function sanitizeSchemaParams<P>(
|
|||||||
? defaults
|
? defaults
|
||||||
: { width: w, height: h };
|
: { width: w, height: h };
|
||||||
out[key] = {
|
out[key] = {
|
||||||
width: clamp(sourceSize.width, spec.min, spec.max),
|
width: clamp(
|
||||||
height: clamp(sourceSize.height, spec.min, spec.max),
|
sourceSize.width,
|
||||||
|
spec.min,
|
||||||
|
dimensionAxisMax(spec, "width", context?.source),
|
||||||
|
),
|
||||||
|
height: clamp(
|
||||||
|
sourceSize.height,
|
||||||
|
spec.min,
|
||||||
|
dimensionAxisMax(spec, "height", context?.source),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type {
|
|||||||
Page,
|
Page,
|
||||||
PageStep,
|
PageStep,
|
||||||
ResultKind,
|
ResultKind,
|
||||||
|
ResultNote,
|
||||||
Tool,
|
Tool,
|
||||||
ToolContext,
|
ToolContext,
|
||||||
ToolImageFile,
|
ToolImageFile,
|
||||||
|
|||||||
@@ -199,14 +199,14 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
const tool = TOOLS.find((t) => t.id === "create-empty")!;
|
const tool = TOOLS.find((t) => t.id === "create-empty")!;
|
||||||
const d = defaultSchemaParams(tool.schema);
|
const d = defaultSchemaParams(tool.schema);
|
||||||
expect(d.size).toEqual({ width: 800, height: 600 });
|
expect(d.size).toEqual({ width: 800, height: 600 });
|
||||||
expect(d.transparent).toBe(true);
|
expect(d.opacity).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitize клампит dimension к min/max и чинит мусор", () => {
|
it("sanitize клампит dimension к min/max и чинит мусор", () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "create-empty")!;
|
const tool = TOOLS.find((t) => t.id === "create-empty")!;
|
||||||
const s = sanitizeSchemaParams(tool.schema, {
|
const s = sanitizeSchemaParams(tool.schema, {
|
||||||
size: { width: 999999, height: -5 },
|
size: { width: 999999, height: -5 },
|
||||||
transparent: false,
|
opacity: 50,
|
||||||
color: "#ff0000",
|
color: "#ff0000",
|
||||||
});
|
});
|
||||||
expect(s.size).toEqual({ width: 20000, height: 1 });
|
expect(s.size).toEqual({ width: 20000, height: 1 });
|
||||||
@@ -218,7 +218,7 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
const tool = TOOLS.find((t) => t.id === "create-empty")!;
|
const tool = TOOLS.find((t) => t.id === "create-empty")!;
|
||||||
const params = sanitizeSchemaParams(tool.schema, {
|
const params = sanitizeSchemaParams(tool.schema, {
|
||||||
size: { width: 320, height: 200 },
|
size: { width: 320, height: 200 },
|
||||||
transparent: true,
|
opacity: 0,
|
||||||
color: "#ff0000",
|
color: "#ff0000",
|
||||||
});
|
});
|
||||||
const img = asImage(await tool.run({ params }));
|
const img = asImage(await tool.run({ params }));
|
||||||
@@ -262,7 +262,7 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
rows: 3,
|
rows: 3,
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
transparentBg: true,
|
bgOpacity: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])("генератор %s даёт картинку по dimension", async (id, params) => {
|
])("генератор %s даёт картинку по dimension", async (id, params) => {
|
||||||
@@ -336,7 +336,7 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
source: img,
|
source: img,
|
||||||
params: sanitizeSchemaParams(tool.schema, {
|
params: sanitizeSchemaParams(tool.schema, {
|
||||||
size: { width: 60, height: 30 },
|
size: { width: 60, height: 30 },
|
||||||
transparent: true,
|
opacity: 0,
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -346,6 +346,53 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
expect(out.data[3]).toBe(0);
|
expect(out.data[3]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("crop: resultNote предупреждает, когда результат ужался по границам", () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "crop")!;
|
||||||
|
expect(
|
||||||
|
tool.resultNote?.(
|
||||||
|
{ x: 700, y: 0, size: { width: 256, height: 256 } },
|
||||||
|
solid(132, 256),
|
||||||
|
),
|
||||||
|
).toEqual({ key: "resultCard.clamped" });
|
||||||
|
expect(
|
||||||
|
tool.resultNote?.(
|
||||||
|
{ x: 0, y: 0, size: { width: 256, height: 256 } },
|
||||||
|
solid(256, 256),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resize: resultNote при keepAspect и скорректированной стороне", () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "resize")!;
|
||||||
|
const p = { size: { width: 500, height: 300 }, keepAspect: true };
|
||||||
|
expect(tool.resultNote?.(p, solid(300, 300))).toEqual({
|
||||||
|
key: "resultCard.aspectAdjusted",
|
||||||
|
tone: "info",
|
||||||
|
});
|
||||||
|
expect(tool.resultNote?.(p, solid(500, 300))).toBeNull();
|
||||||
|
// без keepAspect результат всегда как запрошено
|
||||||
|
expect(
|
||||||
|
tool.resultNote?.(
|
||||||
|
{ size: { width: 500, height: 300 }, keepAspect: false },
|
||||||
|
solid(500, 300),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fit-on-background: resultNote, когда картинка крупнее канваса", () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "fit-on-background")!;
|
||||||
|
const p = {
|
||||||
|
size: { width: 100, height: 100 },
|
||||||
|
opacity: 100,
|
||||||
|
color: "#ffffff",
|
||||||
|
};
|
||||||
|
expect(tool.resultNote?.(p, solid(200, 100))).toEqual({
|
||||||
|
key: "resultCard.grown",
|
||||||
|
tone: "info",
|
||||||
|
});
|
||||||
|
expect(tool.resultNote?.(p, solid(100, 100))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("change-canvas-size использует anchor для позиции", async () => {
|
it("change-canvas-size использует anchor для позиции", async () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "change-canvas-size")!;
|
const tool = TOOLS.find((t) => t.id === "change-canvas-size")!;
|
||||||
const img = solid(10, 10);
|
const img = solid(10, 10);
|
||||||
@@ -633,7 +680,7 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
bold: true,
|
bold: true,
|
||||||
color: "#111318",
|
color: "#111318",
|
||||||
});
|
});
|
||||||
expect(d.transparentBg).toBe(false);
|
expect(d.bgOpacity).toBe(100);
|
||||||
const s = sanitizeSchemaParams(tool.schema, {
|
const s = sanitizeSchemaParams(tool.schema, {
|
||||||
style: { font: "sans", size: 9999, bold: false, color: "#123abc" },
|
style: { font: "sans", size: 9999, bold: false, color: "#123abc" },
|
||||||
padding: 200,
|
padding: 200,
|
||||||
@@ -699,18 +746,18 @@ describe("split-into-parts", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("работает максимум 6x6 = 36 частей", async () => {
|
it("принимает максимальную сетку 32x32 = 1024 части (потолок стража)", async () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
||||||
const files = asFiles(
|
const files = asFiles(
|
||||||
await tool.run({
|
await tool.run({
|
||||||
source: solid(120, 120),
|
source: solid(120, 120),
|
||||||
params: sanitizeSchemaParams(tool.schema, {
|
params: sanitizeSchemaParams(tool.schema, {
|
||||||
columns: 6,
|
columns: 32,
|
||||||
rows: 6,
|
rows: 32,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
).files;
|
).files;
|
||||||
expect(files).toHaveLength(36);
|
expect(files).toHaveLength(1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("выбрасывает errors.tooManyParts при переполнении", () => {
|
it("выбрасывает errors.tooManyParts при переполнении", () => {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function angleGradient(
|
|||||||
|
|
||||||
interface CreateEmptyParams {
|
interface CreateEmptyParams {
|
||||||
size: Dimension;
|
size: Dimension;
|
||||||
transparent: boolean;
|
opacity: number;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,14 +92,20 @@ export const createEmptySchema = toolSchema<CreateEmptyParams>(
|
|||||||
height: 600,
|
height: 600,
|
||||||
presets: true,
|
presets: true,
|
||||||
}),
|
}),
|
||||||
transparent: field.checkbox({ label: "fields.transparent", default: true }),
|
opacity: field.slider({
|
||||||
|
label: "fields.opacity",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
default: 0,
|
||||||
|
}),
|
||||||
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
|
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
layout: {
|
layout: {
|
||||||
groups: [
|
groups: [
|
||||||
{ title: "groups.canvas", fields: ["size"] },
|
{ title: "groups.canvas", fields: ["size"] },
|
||||||
{ title: "groups.fill", fields: ["transparent", "color"] },
|
{ title: "groups.fill", fields: ["opacity", "color"] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -112,10 +118,9 @@ const createEmpty: Tool<CreateEmptyParams> = {
|
|||||||
run: genTool((p) => {
|
run: genTool((p) => {
|
||||||
const w = Math.trunc(p.size.width);
|
const w = Math.trunc(p.size.width);
|
||||||
const h = Math.trunc(p.size.height);
|
const h = Math.trunc(p.size.height);
|
||||||
if (p.transparent) {
|
const [r, g, b] = rgba(p.color).slice(0, 3);
|
||||||
return solidImage(w, h, [0, 0, 0, 0]);
|
const alpha = Math.round((Math.trunc(p.opacity) / 100) * 255);
|
||||||
}
|
return solidImage(w, h, [r, g, b, alpha]);
|
||||||
return solidImage(w, h, rgba(p.color));
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,7 +357,7 @@ interface DrawGridParams {
|
|||||||
rows: number;
|
rows: number;
|
||||||
lineWidth: number;
|
lineWidth: number;
|
||||||
color: string;
|
color: string;
|
||||||
transparentBg: boolean;
|
bgOpacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const drawGridSchema = toolSchema<DrawGridParams>(
|
export const drawGridSchema = toolSchema<DrawGridParams>(
|
||||||
@@ -387,9 +392,12 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
|
|||||||
default: 2,
|
default: 2,
|
||||||
}),
|
}),
|
||||||
color: field.color({ label: "fields.lineColor", default: "#111318" }),
|
color: field.color({ label: "fields.lineColor", default: "#111318" }),
|
||||||
transparentBg: field.checkbox({
|
bgOpacity: field.slider({
|
||||||
label: "fields.transparentBg",
|
label: "fields.opacity",
|
||||||
default: true,
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
default: 0,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -399,7 +407,7 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
|
|||||||
{
|
{
|
||||||
title: "groups.grid",
|
title: "groups.grid",
|
||||||
cols: 2,
|
cols: 2,
|
||||||
fields: ["cols", "rows", "lineWidth", "color", "transparentBg"],
|
fields: ["cols", "rows", "lineWidth", "color", "bgOpacity"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -413,15 +421,7 @@ const drawGridTool: Tool<DrawGridParams> = {
|
|||||||
run: genTool((p) => {
|
run: genTool((p) => {
|
||||||
const w = Math.trunc(p.size.width);
|
const w = Math.trunc(p.size.width);
|
||||||
const h = Math.trunc(p.size.height);
|
const h = Math.trunc(p.size.height);
|
||||||
return drawGrid(
|
return drawGrid(w, h, p.cols, p.rows, p.lineWidth, p.color, p.bgOpacity);
|
||||||
w,
|
|
||||||
h,
|
|
||||||
p.cols,
|
|
||||||
p.rows,
|
|
||||||
p.lineWidth,
|
|
||||||
p.color,
|
|
||||||
p.transparentBg,
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -481,7 +481,7 @@ const placeholder: Tool<PlaceholderParams> = {
|
|||||||
bold: true,
|
bold: true,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
backgroundColor: p.backgroundColor,
|
backgroundColor: p.backgroundColor,
|
||||||
transparentBg: false,
|
bgOpacity: 100,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
});
|
});
|
||||||
out = changeCanvasSize(label, w, h, "center");
|
out = changeCanvasSize(label, w, h, "center");
|
||||||
@@ -931,7 +931,7 @@ const sortColorsTool: Tool<SortColorsParams> = {
|
|||||||
interface TextToPngParams {
|
interface TextToPngParams {
|
||||||
text: string;
|
text: string;
|
||||||
style: FontStyle;
|
style: FontStyle;
|
||||||
transparentBg: boolean;
|
bgOpacity: number;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
padding: number;
|
padding: number;
|
||||||
}
|
}
|
||||||
@@ -952,9 +952,12 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
|
|||||||
bold: true,
|
bold: true,
|
||||||
color: "#111318",
|
color: "#111318",
|
||||||
}),
|
}),
|
||||||
transparentBg: field.checkbox({
|
bgOpacity: field.slider({
|
||||||
label: "fields.transparentBg",
|
label: "fields.opacity",
|
||||||
default: false,
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
default: 100,
|
||||||
}),
|
}),
|
||||||
backgroundColor: field.color({
|
backgroundColor: field.color({
|
||||||
label: "fields.backgroundColor",
|
label: "fields.backgroundColor",
|
||||||
@@ -974,7 +977,7 @@ export const textToPngSchema = toolSchema<TextToPngParams>(
|
|||||||
{ title: "groups.text", fields: ["text", "style"] },
|
{ title: "groups.text", fields: ["text", "style"] },
|
||||||
{
|
{
|
||||||
title: "groups.background",
|
title: "groups.background",
|
||||||
fields: ["transparentBg", "backgroundColor"],
|
fields: ["bgOpacity", "backgroundColor"],
|
||||||
},
|
},
|
||||||
{ title: "groups.padding", fields: ["padding"] },
|
{ title: "groups.padding", fields: ["padding"] },
|
||||||
],
|
],
|
||||||
@@ -995,7 +998,7 @@ const textToPng: Tool<TextToPngParams> = {
|
|||||||
bold: p.style.bold,
|
bold: p.style.bold,
|
||||||
color: p.style.color,
|
color: p.style.color,
|
||||||
backgroundColor: p.backgroundColor,
|
backgroundColor: p.backgroundColor,
|
||||||
transparentBg: p.transparentBg,
|
bgOpacity: p.bgOpacity,
|
||||||
padding: p.padding,
|
padding: p.padding,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const addBorder: Tool<AddBorderParams> = {
|
|||||||
|
|
||||||
interface FitOnBackgroundParams {
|
interface FitOnBackgroundParams {
|
||||||
size: Dimension;
|
size: Dimension;
|
||||||
transparent: boolean;
|
opacity: number;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +79,12 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
|
|||||||
height: 600,
|
height: 600,
|
||||||
presets: true,
|
presets: true,
|
||||||
}),
|
}),
|
||||||
transparent: field.checkbox({
|
opacity: field.slider({
|
||||||
label: "fields.transparent",
|
label: "fields.opacity",
|
||||||
default: false,
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
default: 100,
|
||||||
}),
|
}),
|
||||||
color: field.color({ label: "fields.background", default: "#ffffff" }),
|
color: field.color({ label: "fields.background", default: "#ffffff" }),
|
||||||
},
|
},
|
||||||
@@ -89,7 +92,7 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
|
|||||||
layout: {
|
layout: {
|
||||||
groups: [
|
groups: [
|
||||||
{ title: "groups.canvas", fields: ["size"] },
|
{ title: "groups.canvas", fields: ["size"] },
|
||||||
{ title: "groups.background", fields: ["transparent", "color"] },
|
{ title: "groups.background", fields: ["opacity", "color"] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -113,9 +116,15 @@ const fitOnBackground: Tool<FitOnBackgroundParams> = {
|
|||||||
top,
|
top,
|
||||||
Math.max(0, width - img.width - left),
|
Math.max(0, width - img.width - left),
|
||||||
Math.max(0, height - img.height - top),
|
Math.max(0, height - img.height - top),
|
||||||
p.transparent ? undefined : p.color,
|
p.color,
|
||||||
|
Math.round((Math.trunc(p.opacity) / 100) * 255),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
resultNote: (p, result) =>
|
||||||
|
result.width !== Math.trunc(p.size.width) ||
|
||||||
|
result.height !== Math.trunc(p.size.height)
|
||||||
|
? { key: "resultCard.grown", tone: "info" }
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ChangeCanvasSizeParams {
|
interface ChangeCanvasSizeParams {
|
||||||
@@ -226,6 +235,12 @@ const resizeTool: Tool<ResizeParams> = {
|
|||||||
}
|
}
|
||||||
return resize(img, w, h);
|
return resize(img, w, h);
|
||||||
}),
|
}),
|
||||||
|
resultNote: (p, result) =>
|
||||||
|
p.keepAspect &&
|
||||||
|
(result.width !== Math.trunc(p.size.width) ||
|
||||||
|
result.height !== Math.trunc(p.size.height))
|
||||||
|
? { key: "resultCard.aspectAdjusted", tone: "info" }
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CropParams {
|
interface CropParams {
|
||||||
@@ -239,24 +254,29 @@ export const cropSchema = toolSchema<CropParams>(
|
|||||||
x: field.number({
|
x: field.number({
|
||||||
label: "fields.x",
|
label: "fields.x",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 2000,
|
max: 20000,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 0,
|
default: 0,
|
||||||
|
maxFromSource: "width",
|
||||||
|
maxMinus: 1,
|
||||||
}),
|
}),
|
||||||
y: field.number({
|
y: field.number({
|
||||||
label: "fields.y",
|
label: "fields.y",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1200,
|
max: 20000,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 0,
|
default: 0,
|
||||||
|
maxFromSource: "height",
|
||||||
|
maxMinus: 1,
|
||||||
}),
|
}),
|
||||||
size: field.dimension({
|
size: field.dimension({
|
||||||
label: "fields.cropAreaSize",
|
label: "fields.cropAreaSize",
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 2000,
|
max: 20000,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
defaultFromSource: true,
|
defaultFromSource: true,
|
||||||
|
maxFromSource: true,
|
||||||
presets: true,
|
presets: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -282,6 +302,11 @@ const cropTool: Tool<CropParams> = {
|
|||||||
}
|
}
|
||||||
return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h);
|
return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h);
|
||||||
}),
|
}),
|
||||||
|
resultNote: (p, result) =>
|
||||||
|
result.width < Math.trunc(p.size.width) ||
|
||||||
|
result.height < Math.trunc(p.size.height)
|
||||||
|
? { key: "resultCard.clamped" }
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RotateParams {
|
interface RotateParams {
|
||||||
@@ -331,7 +356,7 @@ const flipTool: Tool<FlipParams> = {
|
|||||||
|
|
||||||
interface AddPaddingParams {
|
interface AddPaddingParams {
|
||||||
padding: number;
|
padding: number;
|
||||||
transparent: boolean;
|
opacity: number;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,14 +369,20 @@ export const addPaddingSchema = toolSchema<AddPaddingParams>(
|
|||||||
step: 1,
|
step: 1,
|
||||||
default: 10,
|
default: 10,
|
||||||
}),
|
}),
|
||||||
transparent: field.checkbox({ label: "fields.transparent", default: true }),
|
opacity: field.slider({
|
||||||
|
label: "fields.opacity",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
default: 0,
|
||||||
|
}),
|
||||||
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
|
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
layout: {
|
layout: {
|
||||||
groups: [
|
groups: [
|
||||||
{ title: "groups.padding", fields: ["padding"] },
|
{ title: "groups.padding", fields: ["padding"] },
|
||||||
{ title: "groups.fill", fields: ["transparent", "color"] },
|
{ title: "groups.fill", fields: ["opacity", "color"] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -368,7 +399,8 @@ const addPaddingTool: Tool<AddPaddingParams> = {
|
|||||||
p.padding,
|
p.padding,
|
||||||
p.padding,
|
p.padding,
|
||||||
p.padding,
|
p.padding,
|
||||||
p.transparent ? undefined : p.color,
|
p.color,
|
||||||
|
Math.round((Math.trunc(p.opacity) / 100) * 255),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -402,7 +434,7 @@ const tileTool: Tool<TileParams> = {
|
|||||||
run: imgTool((img, p) => tile(img, p.columns, p.rows)),
|
run: imgTool((img, p) => tile(img, p.columns, p.rows)),
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_SPLIT_PARTS = 1000;
|
const MAX_SPLIT_PARTS = 1024;
|
||||||
|
|
||||||
interface SplitPartsParams {
|
interface SplitPartsParams {
|
||||||
columns: number;
|
columns: number;
|
||||||
@@ -413,14 +445,14 @@ export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
|||||||
columns: field.number({
|
columns: field.number({
|
||||||
label: "fields.columns",
|
label: "fields.columns",
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 6,
|
max: 32,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 2,
|
default: 2,
|
||||||
}),
|
}),
|
||||||
rows: field.number({
|
rows: field.number({
|
||||||
label: "fields.rows",
|
label: "fields.rows",
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 6,
|
max: 32,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 2,
|
default: 2,
|
||||||
}),
|
}),
|
||||||
@@ -438,7 +470,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
|||||||
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
||||||
const count = cols * rowsCount;
|
const count = cols * rowsCount;
|
||||||
if (count > MAX_SPLIT_PARTS) {
|
if (count > MAX_SPLIT_PARTS) {
|
||||||
throw new ToolError("errors.tooManyParts", { count });
|
throw new ToolError("errors.tooManyParts", {
|
||||||
|
count,
|
||||||
|
max: MAX_SPLIT_PARTS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const parts = splitToParts(img, cols, rowsCount);
|
const parts = splitToParts(img, cols, rowsCount);
|
||||||
const rowLen = String(rowsCount).length;
|
const rowLen = String(rowsCount).length;
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export interface VerdictResult {
|
|||||||
|
|
||||||
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
|
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
|
||||||
|
|
||||||
|
/** Заметка у тайла результата: i18n-ключ и тон бейджа (warning по умолчанию). */
|
||||||
|
export type ResultNote = { key: string; tone?: "warning" | "info" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
|
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
|
||||||
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
|
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
|
||||||
@@ -86,6 +89,12 @@ export type Tool<P = Record<string, unknown>> = {
|
|||||||
domOnly?: boolean;
|
domOnly?: boolean;
|
||||||
/** Формат/качество скачивания результата; по умолчанию — PNG. */
|
/** Формат/качество скачивания результата; по умолчанию — PNG. */
|
||||||
output?: OutputFormat;
|
output?: OutputFormat;
|
||||||
|
/**
|
||||||
|
* Заметка у тайла результата, когда итог отличается от запрошенного:
|
||||||
|
* ужат по границам (crop), скорректирован по пропорциям (resize),
|
||||||
|
* вырос за канвас (fit-on-background).
|
||||||
|
*/
|
||||||
|
resultNote?: (params: P, result: PixelImage) => ResultNote | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */
|
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */
|
||||||
|
|||||||
Reference in New Issue
Block a user