mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
Compare commits
12
Commits
260a738ce4
...
225a496ac7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225a496ac7 | ||
|
|
6b946bfb08 | ||
|
|
e87bc1d715 | ||
|
|
864f9cdc9f | ||
|
|
91150a14bf | ||
|
|
4d9cd2cfcb | ||
|
|
e73986fefd | ||
|
|
5743b9fb3f | ||
|
|
08608b878a | ||
|
|
409c98ca29 | ||
|
|
62bdd5a7c5 | ||
|
|
f40a7c89ae |
@@ -2,6 +2,22 @@
|
|||||||
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.2.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f40a7c89ae5615f2c16f1154116d35fdc0e53125..v0.2.0) - 2026-10-04
|
||||||
|
#### Features
|
||||||
|
- add pipeline flow - ([6b946bf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b946bfb08729ead2515e87406cb3efd22ca9859)) - Ku6epXBOCTuK
|
||||||
|
- add select format to download button - ([e87bc1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e87bc1d7159e91acbf297351710c367eae543011)) - Ku6epXBOCTuK
|
||||||
|
- upload source by ctrl+v and dropzone - ([864f9cd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/864f9cdc9f355292d688a46f1d0383a893d252d3)) - Ku6epXBOCTuK
|
||||||
|
- add size presets for tools - ([91150a1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91150a14bf8203627132f6d3a409109845ed749f)) - Ku6epXBOCTuK
|
||||||
|
- touched fields dont reset on change source image - ([5743b9f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5743b9fb3f1eb7f3b4f9b01e35c9a0f897ee7411)) - Ku6epXBOCTuK
|
||||||
|
#### Bug Fixes
|
||||||
|
- keep aspect ratio flow - ([4d9cd2c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4d9cd2cfcbe6491dfe2ee6074e8f8c1d8abeee74)) - Ku6epXBOCTuK
|
||||||
|
- update range slider field component - ([e73986f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e73986fefd7306391711a39f719e48f60f5d3292)) - Ku6epXBOCTuK
|
||||||
|
- add title from i18n - ([62bdd5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/62bdd5a7c582c74118e964058f84108eaf0cf4cc)) - Ku6epXBOCTuK
|
||||||
|
#### Refactoring
|
||||||
|
- reuse badge component - ([409c98c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/409c98ca295be7b4541148c9568ed9b89524e37a)) - Ku6epXBOCTuK
|
||||||
|
|
||||||
|
- - -
|
||||||
|
|
||||||
## [v0.1.1](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/1db83d12ff717bc2e282fe21d35d282f261147f5..v0.1.1) - 2026-10-04
|
## [v0.1.1](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/1db83d12ff717bc2e282fe21d35d282f261147f5..v0.1.1) - 2026-10-04
|
||||||
#### Bug Fixes
|
#### Bug Fixes
|
||||||
- google analytics only on production - ([e1fc11c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1fc11cb5491a6f5be5c823140830672ea669a62)) - Ku6epXBOCTuK
|
- google analytics only on production - ([e1fc11c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1fc11cb5491a6f5be5c823140830672ea669a62)) - Ku6epXBOCTuK
|
||||||
|
|||||||
+32
-40
@@ -16,31 +16,49 @@
|
|||||||
|
|
||||||
## Мелочи всякие
|
## Мелочи всякие
|
||||||
|
|
||||||
|
- [ ] названия инструментов - сейчас в pipeline инструмент называется "обрезать
|
||||||
|
png", но он работает с любыми картинками, наверное надо убрать. но в
|
||||||
|
заголовке - возможно надо все форматы указать (SEO задача?)
|
||||||
|
- [ ] кнопка добавить инструмент в pipeline - обдумать селектор с номальным
|
||||||
|
поиском, фильтром возможных инструментов и т.д.
|
||||||
|
- [ ] pipeline - промежуточные результаты
|
||||||
|
- [ ] pipeline - drag-n-drop инструментов
|
||||||
- [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
|
- [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
|
||||||
нарезки ассетов
|
нарезки ассетов
|
||||||
- [ ] crop, resize (но скорее всего все инструменты) - при загрузке нового
|
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
|
||||||
изображения сбрасывает размеры, настроенные пользователем
|
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
|
||||||
- [ ] split-into-parts-png, resize (может еще какие-то) - возможность указывать
|
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
|
||||||
размеры из заранее одобренного списка размеров - 16х16, 32х32, 64х64 как
|
схема + core, из задачи C3)
|
||||||
минимум, может еще какие-то размеры добавить, единый список из предыдущей
|
|
||||||
задачи - (для инди разработчиков, стандартные тайлы часто делаются)
|
|
||||||
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
||||||
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
||||||
(`docs/testing-strategy.md`, раздел 5)
|
(`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` («нет параметров — результат готов»): его
|
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
||||||
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
||||||
схемой (FA-E1), либо удалить ключ из обеих локалей
|
схемой (FA-E1), либо удалить ключ из обеих локалей
|
||||||
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
||||||
привязанные к разметке или svelte-классам (сейчас остались:
|
привязанные к разметке или svelte-классам (сейчас остались:
|
||||||
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
||||||
|
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
||||||
|
правило такое (запретить toContain без i18n)
|
||||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||||
выделенной области
|
выделенной области
|
||||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||||
какой размер и 2 сетки)
|
какой размер - 112\56\28px или 72\36\18)
|
||||||
|
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
|
||||||
|
файлов, посмотреть реализацию в
|
||||||
|
[https://github.com/tma02/twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
|
||||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||||
- [ ] можно ли в тестах получать i18n ключ вместо текста?
|
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
|
||||||
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
|
и т.д., сделать lint правило
|
||||||
|
- [ ] комментарии на английском, lint правило
|
||||||
|
- [ ] уменьшить количество и многословность комментариев (lint правило?)
|
||||||
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
||||||
должны быть настраиваемой 0-100%, а не переключателем
|
должны быть настраиваемой 0-100%, а не переключателем
|
||||||
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
||||||
@@ -51,11 +69,6 @@
|
|||||||
`ondownloadtxt`, `ondownload`,
|
`ondownloadtxt`, `ondownload`,
|
||||||
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
||||||
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
||||||
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть
|
|
||||||
в кнопке Download
|
|
||||||
- [ ] Кнопка download должна сразу писать какой формат сейчас, быть с селектором
|
|
||||||
формата и подсвечивать предупреждение если формат не совместим (альфа
|
|
||||||
канал)
|
|
||||||
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
||||||
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
|
||||||
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
|
||||||
@@ -64,13 +77,8 @@
|
|||||||
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
|
||||||
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
|
||||||
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
|
||||||
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
|
|
||||||
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
|
||||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
||||||
- [ ] для range slider field - добавить поле для ввода цифр и кнопку сброса
|
|
||||||
значения
|
|
||||||
- [ ] для keep Aspect field - пересчитывать второе поле, учитывать какое было
|
|
||||||
изменено
|
|
||||||
- [ ] обновить страницу kit или сделать storybook
|
- [ ] обновить страницу 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
|
||||||
@@ -79,16 +87,6 @@
|
|||||||
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
|
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
|
||||||
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
|
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
|
||||||
вывода
|
вывода
|
||||||
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
|
|
||||||
удаление, добавление инструментов. переход один инструмент - pipeline без
|
|
||||||
перезагрузки
|
|
||||||
- [ ] `CatalogToolbar`: фильтры используют сырой `<button>`, а не `ui/Button` —
|
|
||||||
единый контрол для всей библиотеки
|
|
||||||
(`web/src/lib/components/CatalogToolbar.svelte`).
|
|
||||||
- [ ] `SchemaTextResult` вердикт-бейдж рисует руками: свои классы
|
|
||||||
`badge`/`badge-tone--{tone}` и свой CSS в `<style>` вместо
|
|
||||||
`ui/Badge.svelte` — тон и разметка бейджа живут в двух местах
|
|
||||||
(`web/src/lib/components/schema/SchemaTextResult.svelte`).
|
|
||||||
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
|
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
|
||||||
поэтому она не следует за пропорциями результата — широкий результат
|
поэтому она не следует за пропорциями результата — широкий результат
|
||||||
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
|
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
|
||||||
@@ -98,6 +96,11 @@
|
|||||||
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
|
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
|
||||||
в дизайне, а где должен быть 0
|
в дизайне, а где должен быть 0
|
||||||
|
|
||||||
|
## Инструменты
|
||||||
|
|
||||||
|
- [ ] qr code to text, text to qr code
|
||||||
|
- [ ] bar code to text, text to bar code
|
||||||
|
|
||||||
## Переезд
|
## Переезд
|
||||||
|
|
||||||
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
|
||||||
@@ -114,8 +117,6 @@
|
|||||||
и порядок — `docs/plan-seo.md` §6, R1/R2)
|
и порядок — `docs/plan-seo.md` §6, R1/R2)
|
||||||
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
|
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
|
||||||
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
|
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
|
||||||
- [ ] download с селектором формата PNG/JPG/WebP/BMP для всех инструментов (райз
|
|
||||||
«Download» → S1f)
|
|
||||||
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
|
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
|
||||||
вордстату с уникальным контентом; редиректы только для опечаток и
|
вордстату с уникальным контентом; редиректы только для опечаток и
|
||||||
переименований уже опубликованных страниц; клонов с canonical не делать
|
переименований уже опубликованных страниц; клонов с canonical не делать
|
||||||
@@ -140,15 +141,6 @@
|
|||||||
|
|
||||||
## Качество production UI
|
## Качество production UI
|
||||||
|
|
||||||
- [ ] `/` и `/list-tools` выходят без `<title>`: `svelte:head` есть только на
|
|
||||||
странице инструмента, а готовые ключи `home.defaultTitle`,
|
|
||||||
`catalog.pageTitle`, `catalog.metaDescription` не читает ни один компонент
|
|
||||||
(S1d)
|
|
||||||
- [ ] **Баг e2e:** `tool-flows.spec.ts:179` создаёт контекст через
|
|
||||||
`browser.newContext()`, который не наследует `use.baseURL` из
|
|
||||||
`playwright.config.ts`, — относительный `goto('/tools/flip-png')` внутри
|
|
||||||
`openTool` не резолвится, и тест «работает без Web Worker (fallback)»
|
|
||||||
падает на всех движках. В CI это не видно: job `e2e` неблокирующий
|
|
||||||
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
|
||||||
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
|
||||||
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "easy-png-tools",
|
"name": "easy-png-tools",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+43
-3
@@ -76,8 +76,10 @@ export async function renderText(page: Page, value: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
||||||
const input = page.locator('input[type="file"]');
|
// На странице может быть два input[type=file]: дропзона + кнопка Open image;
|
||||||
await expect(input).toHaveCount(1);
|
// канонический — первый (кнопка в шапке панели).
|
||||||
|
const input = page.locator('input[type="file"]').first();
|
||||||
|
await expect(input).toBeAttached();
|
||||||
await input.setInputFiles({
|
await input.setInputFiles({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
@@ -85,8 +87,38 @@ export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadViaDrop(
|
||||||
|
page: Page,
|
||||||
|
file: SourceFile,
|
||||||
|
): Promise<void> {
|
||||||
|
const dataTransfer = await page.evaluateHandle(
|
||||||
|
({
|
||||||
|
bytes,
|
||||||
|
name,
|
||||||
|
mimeType,
|
||||||
|
}: {
|
||||||
|
bytes: number[];
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
}) => {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(new File([new Uint8Array(bytes)], name, { type: mimeType }));
|
||||||
|
return dt;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bytes: Array.from(file.buffer),
|
||||||
|
name: file.name,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /drop a png/i })
|
||||||
|
.dispatchEvent("drop", { dataTransfer });
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadResultFile(page: Page): Promise<Download> {
|
export async function downloadResultFile(page: Page): Promise<Download> {
|
||||||
const button = page.getByRole("button", { name: "Download result" });
|
// Split-кнопка: основная часть "Download" (image) или "Download ZIP" (files).
|
||||||
|
const button = page.getByRole("button", { name: /^Download( ZIP)?$/ });
|
||||||
await expect(button).toBeVisible();
|
await expect(button).toBeVisible();
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent("download"),
|
page.waitForEvent("download"),
|
||||||
@@ -95,6 +127,14 @@ export async function downloadResultFile(page: Page): Promise<Download> {
|
|||||||
return download;
|
return download;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function chooseDownloadFormat(
|
||||||
|
page: Page,
|
||||||
|
format: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Choose download format" }).click();
|
||||||
|
await page.getByRole("menuitemradio", { name: format, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadResultBytes(
|
export async function downloadResultBytes(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<{ name: string; bytes: Uint8Array }> {
|
): Promise<{ name: string; bytes: Uint8Array }> {
|
||||||
|
|||||||
+106
-3
@@ -6,8 +6,10 @@ import {
|
|||||||
largePng,
|
largePng,
|
||||||
onePixelPng,
|
onePixelPng,
|
||||||
opaquePng,
|
opaquePng,
|
||||||
|
transparentPng,
|
||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
|
chooseDownloadFormat,
|
||||||
downloadResultBytes,
|
downloadResultBytes,
|
||||||
errorAlert,
|
errorAlert,
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
sourceImage,
|
sourceImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
uploadViaDrop,
|
||||||
useEnglish,
|
useEnglish,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
@@ -36,6 +39,36 @@ test("resize-png uses the source dimensions by default", async ({ page }) => {
|
|||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resize-png keeps user-set size when another image is uploaded", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openTool(page, "resize-png");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
const width = page.getByRole("spinbutton", { name: "Width" });
|
||||||
|
await width.fill("25");
|
||||||
|
// keepAspect включён по умолчанию: высота пересчитана из 64×48 → 19
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||||
|
"19",
|
||||||
|
);
|
||||||
|
await uploadImage(page, landscapePng);
|
||||||
|
await expect(width).toHaveValue("25");
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||||
|
"19",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resize-png applies size preset chips", async ({ page }) => {
|
||||||
|
await openTool(page, "resize-png");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await page.getByRole("button", { name: "32×32" }).click();
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||||
|
"32",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||||
|
"32",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("crop-png starts with the full source area", async ({ page }) => {
|
test("crop-png starts with the full source area", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "crop-png");
|
await openTool(page, "crop-png");
|
||||||
@@ -51,6 +84,15 @@ test("crop-png starts with the full source area", async ({ page }) => {
|
|||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("flip-png: upload via drag-and-drop on the dropzone", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadViaDrop(page, opaquePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
test("flip-png: full flow - upload, result, download", async ({ page }) => {
|
test("flip-png: full flow - upload, result, download", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
@@ -104,6 +146,26 @@ test("convert-png-to-webp: produces WebP and quality affects size", async ({
|
|||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("download format selector: JPG warns about alpha and produces JPEG", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, transparentPng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await chooseDownloadFormat(page, "JPG");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("status").filter({ hasText: /transparency/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
// у JPG в dropdown есть настройка качества
|
||||||
|
await rangeInput(page, "Quality").fill("10");
|
||||||
|
const { name, bytes } = await downloadResultBytes(page);
|
||||||
|
expect(name).toBe("flip-png.jpg");
|
||||||
|
expect(Array.from(bytes.subarray(0, 3))).toEqual([0xff, 0xd8, 0xff]);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
test("png-to-bmp: produces a BMP download", async ({ page }) => {
|
test("png-to-bmp: produces a BMP download", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "png-to-bmp");
|
await openTool(page, "png-to-bmp");
|
||||||
@@ -127,7 +189,7 @@ test("reset restores defaults and re-runs with the source", async ({
|
|||||||
await radius.fill("20");
|
await radius.fill("20");
|
||||||
await expect(radius).toHaveValue("20");
|
await expect(radius).toHaveValue("20");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Reset" }).click();
|
await page.getByRole("button", { name: "Reset", exact: true }).click();
|
||||||
await expect(radius).toHaveValue("4");
|
await expect(radius).toHaveValue("4");
|
||||||
await expect(sourceImage(page)).toBeVisible();
|
await expect(sourceImage(page)).toBeVisible();
|
||||||
await expect(resultImage(page)).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
@@ -175,8 +237,12 @@ test("blur: changing slider updates result image", async ({ page }) => {
|
|||||||
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
|
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
test("runs without Web Worker (fallback)", async ({ browser }, testInfo) => {
|
||||||
const context = await browser.newContext();
|
// newContext() не наследует use.baseURL из конфига — пробрасываем явно,
|
||||||
|
// иначе относительный goto() в helpers не резолвится.
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: testInfo.project.use.baseURL,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await context.addInitScript(() => {
|
await context.addInitScript(() => {
|
||||||
Object.defineProperty(window, "Worker", {
|
Object.defineProperty(window, "Worker", {
|
||||||
@@ -197,6 +263,43 @@ test("runs without Web Worker (fallback)", async ({ browser }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("chain: add step extends pipeline without reload and persists", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, opaquePng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Add step" }).last().click();
|
||||||
|
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
|
||||||
|
const stepTwo = page.getByRole("heading", {
|
||||||
|
name: "Grayscale PNG",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(stepTwo).toBeVisible();
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
// цепочка переживает перезагрузку (localStorage)
|
||||||
|
await page.reload();
|
||||||
|
await expect(stepTwo).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chain: remove step returns to a single tool", async ({ page }) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await page.getByRole("button", { name: "Add step" }).last().click();
|
||||||
|
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
|
||||||
|
const stepTwo = page.getByRole("heading", {
|
||||||
|
name: "Grayscale PNG",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(stepTwo).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Remove step" }).last().click();
|
||||||
|
await expect(stepTwo).toHaveCount(0);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts"]
|
||||||
|
}
|
||||||
+1
-10
@@ -59,16 +59,7 @@ export default tseslint.config(
|
|||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"eslint.config.js",
|
"eslint.config.js",
|
||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
"e2e/helpers/fixtures.ts",
|
// e2e покрыты e2e/tsconfig.json.
|
||||||
"e2e/helpers/page.ts",
|
|
||||||
"e2e/navigation.spec.ts",
|
|
||||||
"e2e/catalog.spec.ts",
|
|
||||||
"e2e/tool-flows.spec.ts",
|
|
||||||
"e2e/text-and-verdicts.spec.ts",
|
|
||||||
"e2e/tools-smoke.spec.ts",
|
|
||||||
"e2e/generators.spec.ts",
|
|
||||||
"e2e/png-fixtures.spec.ts",
|
|
||||||
"e2e/i18n.spec.ts",
|
|
||||||
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
||||||
// tsconfig), поэтому для типизированного парсинга резолвятся
|
// tsconfig), поэтому для типизированного парсинга резолвятся
|
||||||
// через default-проект. Перечисляются точечно: `**` в
|
// через default-проект. Перечисляются точечно: `**` в
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
+2
-3
@@ -155,9 +155,6 @@
|
|||||||
--size-tool-icon: 42px;
|
--size-tool-icon: 42px;
|
||||||
--size-tool-min-height: 106px;
|
--size-tool-min-height: 106px;
|
||||||
--size-tool-arrow: 18px;
|
--size-tool-arrow: 18px;
|
||||||
--size-step-min-height: 138px;
|
|
||||||
--size-step-index: 46px;
|
|
||||||
--size-step-grip: 20px;
|
|
||||||
--size-card-min: 240px;
|
--size-card-min: 240px;
|
||||||
--size-card-min-narrow: 200px;
|
--size-card-min-narrow: 200px;
|
||||||
--size-parts-grid-min: 120px;
|
--size-parts-grid-min: 120px;
|
||||||
@@ -176,6 +173,8 @@
|
|||||||
|
|
||||||
--duration-s: 0.15s;
|
--duration-s: 0.15s;
|
||||||
--duration-l: 0.8s;
|
--duration-l: 0.8s;
|
||||||
|
|
||||||
|
--z-dropdown: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Ellipsis, GripVertical, X } from "@lucide/svelte";
|
import { GripVertical, X } from "@lucide/svelte";
|
||||||
import IconButton from "$lib/components/ui/IconButton.svelte";
|
import IconButton from "$lib/components/ui/IconButton.svelte";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
@@ -8,63 +8,87 @@
|
|||||||
index: number;
|
index: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
draggable?: boolean;
|
||||||
tools?: Snippet;
|
tools?: Snippet;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
onremove?: () => void;
|
onremove?: () => void;
|
||||||
|
ondragstart?: (e: DragEvent) => void;
|
||||||
|
ondragover?: (e: DragEvent) => void;
|
||||||
|
ondrop?: (e: DragEvent) => void;
|
||||||
|
ondragend?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { index, title, type, tools, children, onremove }: Props = $props();
|
let {
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
draggable = false,
|
||||||
|
tools,
|
||||||
|
children,
|
||||||
|
onremove,
|
||||||
|
ondragstart,
|
||||||
|
ondragover,
|
||||||
|
ondrop,
|
||||||
|
ondragend,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="step-card">
|
<article
|
||||||
<div class="step-index">{index.toString().padStart(2, "0")}</div>
|
class="step-card"
|
||||||
<GripVertical size={16} class="drag" aria-hidden="true" />
|
{draggable}
|
||||||
<div class="step-body">
|
ondragstart={(e) => ondragstart?.(e)}
|
||||||
<div class="step-heading">
|
ondragover={(e) => ondragover?.(e)}
|
||||||
<div class="step-heading-text">
|
ondrop={(e) => ondrop?.(e)}
|
||||||
{#if type}<span class="step-type">{type}</span>{/if}
|
ondragend={() => ondragend?.()}
|
||||||
{#if title}<h2 class="step-title">{title}</h2>{/if}
|
>
|
||||||
</div>
|
<header class="step-head">
|
||||||
<div class="step-tools">
|
<span class="step-index">{index.toString().padStart(2, "0")}</span>
|
||||||
{#if tools}{@render tools()}{/if}
|
<GripVertical size={16} class="drag" aria-hidden="true" />
|
||||||
|
<div class="step-heading-text">
|
||||||
|
{#if type}<span class="step-type">{type}</span>{/if}
|
||||||
|
{#if title}<h2 class="step-title">{title}</h2>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="step-tools">
|
||||||
|
{#if tools}{@render tools()}{/if}
|
||||||
|
{#if onremove}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={X}
|
icon={X}
|
||||||
label={t("chain.removeStepAria")}
|
label={t("chain.removeStepAria")}
|
||||||
variant="clear"
|
variant="clear"
|
||||||
size="s"
|
size="s"
|
||||||
onclick={() => onremove?.()}
|
onclick={() => onremove()}
|
||||||
/>
|
/>
|
||||||
<Ellipsis size={17} />
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#if children}
|
</header>
|
||||||
|
{#if children}
|
||||||
|
<div class="step-body">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.step-card {
|
.step-card {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--size-step-index) var(--size-step-grip) 1fr;
|
|
||||||
min-height: var(--size-step-min-height);
|
|
||||||
border: var(--size-border) solid var(--color-border);
|
border: var(--size-border) solid var(--color-border);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
|
.step-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-m) var(--space-xl);
|
||||||
|
border-bottom: var(--size-border) solid var(--color-border);
|
||||||
|
}
|
||||||
.step-index {
|
.step-index {
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
color: var(--color-main);
|
color: var(--color-main);
|
||||||
padding: var(--space-xl) 0 0px var(--space-xl);
|
|
||||||
}
|
}
|
||||||
.step-body {
|
.step-heading-text {
|
||||||
padding: var(--space-xl) var(--space-xl) var(--space-xl);
|
flex: 1;
|
||||||
}
|
|
||||||
.step-heading {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
gap: var(--space-m);
|
||||||
gap: var(--space-xl);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
}
|
}
|
||||||
.step-type {
|
.step-type {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -72,7 +96,7 @@
|
|||||||
letter-spacing: var(--space-text-xl);
|
letter-spacing: var(--space-text-xl);
|
||||||
}
|
}
|
||||||
.step-title {
|
.step-title {
|
||||||
margin: var(--space-s) 0 var(--space-xl);
|
margin: 0;
|
||||||
font: 600 var(--font-size-m) var(--font-mono);
|
font: 600 var(--font-size-m) var(--font-mono);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -81,11 +105,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-l);
|
gap: var(--space-l);
|
||||||
}
|
}
|
||||||
.step-card :global(.drag) {
|
.step-body {
|
||||||
margin-top: var(--space-xl);
|
padding: var(--space-l) var(--space-xl) var(--space-xl);
|
||||||
color: var(--color-border);
|
|
||||||
}
|
}
|
||||||
.step-body > :not(.step-heading) {
|
.step-card :global(.drag) {
|
||||||
padding: var(--space-l);
|
color: var(--color-border);
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type {
|
import {
|
||||||
Dimension,
|
SIZE_PRESETS,
|
||||||
DimensionSpec,
|
type Dimension,
|
||||||
FieldSpec,
|
type DimensionSpec,
|
||||||
|
type FieldSpec,
|
||||||
} from "$lib/registry-schema";
|
} from "$lib/registry-schema";
|
||||||
|
import ChipButton from "$lib/components/ui/ChipButton.svelte";
|
||||||
import Control from "./Control.svelte";
|
import Control from "./Control.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
spec: FieldSpec;
|
spec: FieldSpec;
|
||||||
onchange?: (value: Dimension) => void;
|
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
|
||||||
}
|
}
|
||||||
let { label, value, spec, onchange }: Props = $props();
|
let { label, value, spec, onchange }: Props = $props();
|
||||||
|
|
||||||
@@ -31,7 +33,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function setAxis(axis: "width" | "height", n: number) {
|
function setAxis(axis: "width" | "height", n: number) {
|
||||||
onchange?.({ ...current, [axis]: n });
|
onchange?.({ ...current, [axis]: n }, axis);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(n: number) {
|
||||||
|
// Пресет задаёт обе оси явно — lockAspect его не пересчитывает.
|
||||||
|
onchange?.({ width: n, height: n }, "both");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,6 +67,17 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{#if sp.presets}
|
||||||
|
<div class="dimension-presets">
|
||||||
|
{#each SIZE_PRESETS as n (n)}
|
||||||
|
<ChipButton
|
||||||
|
label={`${n}×${n}`}
|
||||||
|
active={current.width === n && current.height === n}
|
||||||
|
onclick={() => applyPreset(n)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</Control>
|
</Control>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -81,4 +99,10 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
}
|
}
|
||||||
|
.dimension-presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-s);
|
||||||
|
margin-top: var(--space-m);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FieldSpec, NumberSpec, SliderSpec } from "$lib/registry-schema";
|
import type { FieldSpec, NumberSpec, SliderSpec } from "$lib/registry-schema";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { Minus, Plus, RotateCcw } from "@lucide/svelte";
|
||||||
|
import IconButton from "$lib/components/ui/IconButton.svelte";
|
||||||
import Control from "./Control.svelte";
|
import Control from "./Control.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,33 +14,97 @@
|
|||||||
let { label, value, spec, onchange }: Props = $props();
|
let { label, value, spec, onchange }: Props = $props();
|
||||||
|
|
||||||
const sp = $derived(spec as NumberSpec | SliderSpec);
|
const sp = $derived(spec as NumberSpec | SliderSpec);
|
||||||
|
const min = $derived(sp.min ?? 0);
|
||||||
|
const max = $derived(sp.max ?? 100);
|
||||||
|
const step = $derived(sp.step ?? 1);
|
||||||
const current = $derived(
|
const current = $derived(
|
||||||
typeof value === "number" && Number.isFinite(value) ? value : sp.default,
|
typeof value === "number" && Number.isFinite(value) ? value : sp.default,
|
||||||
);
|
);
|
||||||
|
const atDefault = $derived(current === sp.default);
|
||||||
|
const atMin = $derived(current <= min);
|
||||||
|
const atMax = $derived(current >= max);
|
||||||
|
|
||||||
function handle(e: Event) {
|
function handle(e: Event) {
|
||||||
onchange?.(Number((e.target as HTMLInputElement).value));
|
const n = (e.target as HTMLInputElement).valueAsNumber;
|
||||||
|
if (Number.isFinite(n)) onchange?.(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBy(dir: 1 | -1) {
|
||||||
|
onchange?.(Math.min(max, Math.max(min, current + dir * step)));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Control element="label" {label}>
|
<Control {label}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<output>{current}</output>
|
<span class="range-trailing">
|
||||||
|
<IconButton
|
||||||
|
icon={Minus}
|
||||||
|
label={`${t("ui.decrease")} ${label}`}
|
||||||
|
variant="clear"
|
||||||
|
size="s"
|
||||||
|
disabled={atMin}
|
||||||
|
onclick={() => stepBy(-1)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="range-number"
|
||||||
|
aria-label={label}
|
||||||
|
type="number"
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
value={current}
|
||||||
|
oninput={handle}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={Plus}
|
||||||
|
label={`${t("ui.increase")} ${label}`}
|
||||||
|
variant="clear"
|
||||||
|
size="s"
|
||||||
|
disabled={atMax}
|
||||||
|
onclick={() => stepBy(1)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={RotateCcw}
|
||||||
|
label={`${t("ui.reset")} ${label}`}
|
||||||
|
variant="clear"
|
||||||
|
size="s"
|
||||||
|
disabled={atDefault}
|
||||||
|
onclick={() => onchange?.(sp.default)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<input
|
<input
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
type="range"
|
type="range"
|
||||||
min={sp.min ?? 0}
|
{min}
|
||||||
max={sp.max ?? 100}
|
{max}
|
||||||
step={sp.step ?? 1}
|
{step}
|
||||||
value={current}
|
value={current}
|
||||||
oninput={handle}
|
oninput={handle}
|
||||||
/>
|
/>
|
||||||
</Control>
|
</Control>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
output {
|
.range-trailing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
.range-number {
|
||||||
|
width: calc(var(--space-xxxl) * 3);
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
font: var(--font-size-m) var(--font-mono);
|
||||||
|
text-align: center;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
.range-number::-webkit-outer-spin-button,
|
||||||
|
.range-number::-webkit-inner-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { chainablePages } from "$lib/pipeline.svelte";
|
||||||
|
import type { Page } from "$lib/registry";
|
||||||
|
import { Plus } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onadd: (toolId: string) => void;
|
||||||
|
}
|
||||||
|
let { onadd }: Props = $props();
|
||||||
|
|
||||||
|
const pages = chainablePages();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let root = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
function choose(page: Page) {
|
||||||
|
onadd(page.steps[0].id);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!root?.contains(e.target as Node)) open = false;
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") open = false;
|
||||||
|
}
|
||||||
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="add-step" bind:this={root}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="add-btn"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
>
|
||||||
|
<Icon icon={Plus} size={14} />
|
||||||
|
<span>{t("chain.addStep")}</span>
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
{#each pages as page (page.slug)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => choose(page)}
|
||||||
|
>
|
||||||
|
{pageTitle(page)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.add-step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin: var(--space-s) 0;
|
||||||
|
}
|
||||||
|
.add-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid var(--color-main);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-main);
|
||||||
|
color: var(--color-background);
|
||||||
|
font: 600 var(--font-size-s) var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-s));
|
||||||
|
right: 0;
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
min-width: calc(var(--space-xxxl) * 8);
|
||||||
|
max-height: calc(var(--space-xxxl) * 8);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-s);
|
||||||
|
padding: var(--space-m);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-panel);
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid transparent;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.item:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,31 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
|
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
|
||||||
import UploadButton from "$lib/components/ui/UploadButton.svelte";
|
import UploadButton from "$lib/components/ui/UploadButton.svelte";
|
||||||
|
import type { OutputMime } from "$lib/core/io";
|
||||||
|
import { outputFormatByMime } from "$lib/core/io";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
|
import type { ResultKind } from "$lib/registry";
|
||||||
|
import SchemaDownload from "./SchemaDownload.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
inputMode: "image" | "text" | "none";
|
inputMode: "image" | "text" | "none";
|
||||||
|
resultKind?: ResultKind;
|
||||||
canDownload: boolean;
|
canDownload: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
format?: OutputMime;
|
||||||
|
quality?: number;
|
||||||
|
alphaLoss?: boolean;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
|
onformat?: (mime: OutputMime) => void;
|
||||||
|
onquality?: (value: number) => void;
|
||||||
}
|
}
|
||||||
let { inputMode, canDownload, running, onupload, ondownload }: Props =
|
let {
|
||||||
$props();
|
inputMode,
|
||||||
|
resultKind = "image",
|
||||||
|
canDownload,
|
||||||
|
running,
|
||||||
|
format = "image/png",
|
||||||
|
quality = undefined,
|
||||||
|
alphaLoss = false,
|
||||||
|
onupload,
|
||||||
|
ondownload,
|
||||||
|
onformat,
|
||||||
|
onquality,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if inputMode === "image"}
|
{#if inputMode === "image"}
|
||||||
<UploadButton label={t("actions.openImage")} onfile={onupload} />
|
<UploadButton label={t("actions.openImage")} onfile={onupload} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if canDownload}
|
{#if canDownload && resultKind === "image"}
|
||||||
|
<SchemaDownload
|
||||||
|
{format}
|
||||||
|
{quality}
|
||||||
|
{running}
|
||||||
|
{ondownload}
|
||||||
|
onformat={(v) => onformat?.(v)}
|
||||||
|
onquality={(v) => onquality?.(v)}
|
||||||
|
/>
|
||||||
|
{:else if canDownload && resultKind === "files"}
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
label={t("actions.downloadResult")}
|
label={t("actions.downloadFormat", { format: "ZIP" })}
|
||||||
onclick={ondownload}
|
onclick={ondownload}
|
||||||
disabled={running}
|
disabled={running}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if canDownload && alphaLoss}
|
||||||
|
<p class="alpha-warning" role="status">
|
||||||
|
{t("actions.alphaLoss", { format: outputFormatByMime(format).label })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.actions {
|
.actions {
|
||||||
@@ -34,4 +69,9 @@
|
|||||||
gap: var(--space-l);
|
gap: var(--space-l);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.alpha-warning {
|
||||||
|
margin: var(--space-m) 0 0;
|
||||||
|
color: var(--color-warning);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import {
|
||||||
|
OUTPUT_FORMATS,
|
||||||
|
outputFormatByMime,
|
||||||
|
type OutputMime,
|
||||||
|
} from "$lib/core/io";
|
||||||
|
import type { SliderSpec } from "$lib/registry-schema";
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { ChevronDown, Download } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
format: OutputMime;
|
||||||
|
quality?: number;
|
||||||
|
running: boolean;
|
||||||
|
ondownload: () => void;
|
||||||
|
onformat: (mime: OutputMime) => void;
|
||||||
|
onquality: (value: number) => void;
|
||||||
|
}
|
||||||
|
let { format, quality, running, ondownload, onformat, onquality }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const current = $derived(outputFormatByMime(format));
|
||||||
|
const qualitySetting = $derived(current.settings?.quality);
|
||||||
|
const qualitySpec = $derived(
|
||||||
|
qualitySetting
|
||||||
|
? ({
|
||||||
|
kind: "slider",
|
||||||
|
min: qualitySetting.min,
|
||||||
|
max: qualitySetting.max,
|
||||||
|
step: qualitySetting.step,
|
||||||
|
default: qualitySetting.default,
|
||||||
|
} satisfies SliderSpec)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let root = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
function choose(mime: OutputMime) {
|
||||||
|
// Меню — ещё и панель настроек формата: не закрываем при выборе,
|
||||||
|
// чтобы можно было сразу настроить качество.
|
||||||
|
onformat(mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!root?.contains(e.target as Node)) open = false;
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") open = false;
|
||||||
|
}
|
||||||
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="download-split" bind:this={root}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="main"
|
||||||
|
disabled={running}
|
||||||
|
onclick={() => {
|
||||||
|
open = false;
|
||||||
|
ondownload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={Download} size={14} />
|
||||||
|
<span>{t("actions.download")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={t("actions.formatAria")}
|
||||||
|
disabled={running}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
>
|
||||||
|
<span>{current.label}</span>
|
||||||
|
<Icon icon={ChevronDown} size={14} />
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
{#each OUTPUT_FORMATS as f (f.mime)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="item"
|
||||||
|
class:selected={f.mime === format}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={f.mime === format}
|
||||||
|
onclick={() => choose(f.mime)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if qualitySpec && quality !== undefined}
|
||||||
|
<div class="quality">
|
||||||
|
<RangeControl
|
||||||
|
label={t("fields.quality")}
|
||||||
|
value={quality}
|
||||||
|
spec={qualitySpec}
|
||||||
|
onchange={(q) => onquality(q)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.download-split {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.main,
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-m) var(--space-xl);
|
||||||
|
border: var(--size-border-thick) solid var(--color-main);
|
||||||
|
background: var(--color-main);
|
||||||
|
color: var(--color-background);
|
||||||
|
font-size: var(--font-size-l);
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
border-right: none;
|
||||||
|
border-radius: var(--radius-s) 0 0 var(--radius-s);
|
||||||
|
}
|
||||||
|
.toggle {
|
||||||
|
border-radius: 0 var(--radius-s) var(--radius-s) 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.main:disabled,
|
||||||
|
.toggle:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-s));
|
||||||
|
right: 0;
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
/* Ширина под строку качества: числовое поле + степперы + сброс. */
|
||||||
|
min-width: calc(var(--space-xxxl) * 9);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-s);
|
||||||
|
padding: var(--space-m);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-panel);
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid transparent;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.item:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.item.selected {
|
||||||
|
color: var(--color-main);
|
||||||
|
border-color: var(--color-main);
|
||||||
|
}
|
||||||
|
.quality {
|
||||||
|
padding-top: var(--space-m);
|
||||||
|
border-top: var(--size-border) solid var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
spec: FieldSpec;
|
spec: FieldSpec;
|
||||||
toolId: string;
|
toolId: string;
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
onchange?: (value: unknown) => void;
|
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIELDS: Record<FieldSpecKind, Component<FieldControlProps>> = {
|
const FIELDS: Record<FieldSpecKind, Component<FieldControlProps>> = {
|
||||||
@@ -54,7 +54,11 @@
|
|||||||
schema: ToolSchema<Record<string, unknown>>;
|
schema: ToolSchema<Record<string, unknown>>;
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
toolId: string;
|
toolId: string;
|
||||||
onchange: (id: string, value: unknown) => void;
|
onchange: (
|
||||||
|
id: string,
|
||||||
|
value: unknown,
|
||||||
|
axis?: "width" | "height" | "both",
|
||||||
|
) => void;
|
||||||
onreset: () => void;
|
onreset: () => void;
|
||||||
}
|
}
|
||||||
let { schema, values, toolId, onchange, onreset }: Props = $props();
|
let { schema, values, toolId, onchange, onreset }: Props = $props();
|
||||||
@@ -80,7 +84,7 @@
|
|||||||
spec={schema.fields[id].spec}
|
spec={schema.fields[id].spec}
|
||||||
{toolId}
|
{toolId}
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
onchange={(v) => onchange(id, v)}
|
onchange={(v, axis) => onchange(id, v, axis)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +98,6 @@
|
|||||||
size="s"
|
size="s"
|
||||||
onclick={onreset}
|
onclick={onreset}
|
||||||
/>
|
/>
|
||||||
<span class="auto-note"><i></i> {t("ui.autoUpdate")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -124,10 +127,4 @@
|
|||||||
padding-top: var(--space-l);
|
padding-top: var(--space-l);
|
||||||
border-top: var(--size-border) solid var(--color-border);
|
border-top: var(--size-border) solid var(--color-border);
|
||||||
}
|
}
|
||||||
.auto-note i {
|
|
||||||
width: var(--size-border-thick);
|
|
||||||
height: var(--size-border-thick);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-success);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
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 { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
||||||
|
|
||||||
@@ -20,12 +21,17 @@
|
|||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
format?: OutputMime;
|
||||||
|
quality?: number;
|
||||||
|
alphaLoss?: boolean;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
ontextsource?: (text: string) => void;
|
ontextsource?: (text: string) => void;
|
||||||
onrendertext?: () => void;
|
onrendertext?: () => void;
|
||||||
oncopytext?: () => void;
|
oncopytext?: () => void;
|
||||||
ondownloadtxt?: () => void;
|
ondownloadtxt?: () => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
|
onformat?: (mime: OutputMime) => void;
|
||||||
|
onquality?: (value: number) => void;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
inputMode,
|
inputMode,
|
||||||
@@ -39,12 +45,17 @@
|
|||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
running = false,
|
running = false,
|
||||||
error = "",
|
error = "",
|
||||||
|
format = "image/png",
|
||||||
|
quality = undefined,
|
||||||
|
alphaLoss = false,
|
||||||
onupload,
|
onupload,
|
||||||
ontextsource,
|
ontextsource,
|
||||||
onrendertext,
|
onrendertext,
|
||||||
oncopytext,
|
oncopytext,
|
||||||
ondownloadtxt,
|
ondownloadtxt,
|
||||||
ondownload,
|
ondownload,
|
||||||
|
onformat,
|
||||||
|
onquality,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const model = $derived(
|
const model = $derived(
|
||||||
@@ -59,10 +70,16 @@
|
|||||||
<span class="label">{t("resultCard.previewPanel")}</span>
|
<span class="label">{t("resultCard.previewPanel")}</span>
|
||||||
<SchemaActions
|
<SchemaActions
|
||||||
{inputMode}
|
{inputMode}
|
||||||
|
{resultKind}
|
||||||
canDownload={model.hasResult}
|
canDownload={model.hasResult}
|
||||||
{running}
|
{running}
|
||||||
|
{format}
|
||||||
|
{quality}
|
||||||
|
{alphaLoss}
|
||||||
{onupload}
|
{onupload}
|
||||||
{ondownload}
|
{ondownload}
|
||||||
|
{onformat}
|
||||||
|
{onquality}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,6 +95,7 @@
|
|||||||
{running}
|
{running}
|
||||||
ontextinput={ontextsource}
|
ontextinput={ontextsource}
|
||||||
{onrendertext}
|
{onrendertext}
|
||||||
|
{onupload}
|
||||||
/>
|
/>
|
||||||
<SchemaResultTile
|
<SchemaResultTile
|
||||||
{resultKind}
|
{resultKind}
|
||||||
@@ -97,7 +115,6 @@
|
|||||||
items={[
|
items={[
|
||||||
{ caption: t("sourceCard.source"), value: model.sourceValue },
|
{ caption: t("sourceCard.source"), value: model.sourceValue },
|
||||||
{ caption: t("resultCard.result"), value: model.resultValue },
|
{ caption: t("resultCard.result"), value: model.resultValue },
|
||||||
{ caption: t("resultCard.format"), value: model.formatValue },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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 { InputMode } from "$lib/registry";
|
import type { InputMode } from "$lib/registry";
|
||||||
|
import Dropzone from "./Dropzone.svelte";
|
||||||
import PreviewTile from "./PreviewTile.svelte";
|
import PreviewTile from "./PreviewTile.svelte";
|
||||||
import SchemaTextSource from "./SchemaTextSource.svelte";
|
import SchemaTextSource from "./SchemaTextSource.svelte";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
running?: boolean;
|
running?: boolean;
|
||||||
ontextinput?: (text: string) => void;
|
ontextinput?: (text: string) => void;
|
||||||
onrendertext?: () => void;
|
onrendertext?: () => void;
|
||||||
|
onupload?: (file: File) => void;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
mode,
|
mode,
|
||||||
@@ -21,9 +23,19 @@
|
|||||||
running = false,
|
running = false,
|
||||||
ontextinput,
|
ontextinput,
|
||||||
onrendertext,
|
onrendertext,
|
||||||
|
onupload,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (file) onupload?.(file);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PreviewTile label={t("sourceCard.source")} viewMode={mode}>
|
<PreviewTile label={t("sourceCard.source")} viewMode={mode}>
|
||||||
@@ -37,9 +49,29 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if mode === "image" && sourceUrl}
|
{:else if mode === "image" && sourceUrl}
|
||||||
<img data-testid="source-image" src={sourceUrl} alt={t("sourceCard.alt")} />
|
<div
|
||||||
|
class="tile-drop"
|
||||||
|
class:dragging
|
||||||
|
role="region"
|
||||||
|
aria-label={t("dropZone.overlayDefault")}
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (dragging = false)}
|
||||||
|
ondrop={onDrop}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
data-testid="source-image"
|
||||||
|
src={sourceUrl}
|
||||||
|
alt={t("sourceCard.alt")}
|
||||||
|
/>
|
||||||
|
{#if dragging}
|
||||||
|
<span class="drop-hint">{t("dropZone.overlayDefault")}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if mode === "image"}
|
{:else if mode === "image"}
|
||||||
<span class="empty">{t("sourceCard.chooseImage")}</span>
|
<Dropzone onfile={onupload} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="empty">{t("sourceCard.noSource")}</span>
|
<span class="empty">{t("sourceCard.noSource")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -54,4 +86,32 @@
|
|||||||
padding: var(--space-l);
|
padding: var(--space-l);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
.tile-drop {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.tile-drop.dragging::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: var(--size-border-thick) dashed var(--color-main);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-main-tint);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.tile-drop .drop-hint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--space-l);
|
||||||
|
color: var(--color-main);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { verdictText, verdictTone } from "$lib/i18n/schema-tool-strings";
|
import { verdictText, verdictTone } from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { Copy, Download, FileText } from "@lucide/svelte";
|
import { Copy, Download, FileText } from "@lucide/svelte";
|
||||||
|
import Badge from "$lib/components/ui/Badge.svelte";
|
||||||
import IconButton from "$lib/components/ui/IconButton.svelte";
|
import IconButton from "$lib/components/ui/IconButton.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,14 +33,16 @@
|
|||||||
{#if kind === "verdict"}
|
{#if kind === "verdict"}
|
||||||
<div class="verdict">
|
<div class="verdict">
|
||||||
<span class="verdict-label">{t("resultCard.result")}</span>
|
<span class="verdict-label">{t("resultCard.result")}</span>
|
||||||
<div
|
<Badge
|
||||||
|
{tone}
|
||||||
|
variant="tint"
|
||||||
|
size="m"
|
||||||
data-testid="result-verdict"
|
data-testid="result-verdict"
|
||||||
class="badge badge-tone--{tone}"
|
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<span class="verdict-text">{displayValue}</span>
|
{displayValue}
|
||||||
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
|
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
|
||||||
</div>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-result">
|
<div class="text-result">
|
||||||
@@ -109,31 +112,4 @@
|
|||||||
letter-spacing: var(--space-text-l);
|
letter-spacing: var(--space-text-l);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-l);
|
|
||||||
padding: var(--space-m) var(--space-xl);
|
|
||||||
border: var(--size-border-thick) solid transparent;
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
font: var(--font-size-m) var(--font-mono);
|
|
||||||
}
|
|
||||||
.badge-tone--success {
|
|
||||||
background: var(--color-success-tint);
|
|
||||||
border-color: var(--color-success);
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
.badge-tone--danger {
|
|
||||||
background: var(--color-danger-tint);
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.badge-tone--info {
|
|
||||||
background: var(--color-info-tint);
|
|
||||||
border-color: var(--color-info);
|
|
||||||
color: var(--color-info);
|
|
||||||
}
|
|
||||||
.verdict-text {
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AddStepButton from "./AddStepButton.svelte";
|
||||||
import SchemaFields from "./SchemaFields.svelte";
|
import SchemaFields from "./SchemaFields.svelte";
|
||||||
import SchemaPreview from "./SchemaPreview.svelte";
|
import SchemaPreview from "./SchemaPreview.svelte";
|
||||||
|
import StepCard from "$lib/components/display/StepCard.svelte";
|
||||||
|
import { hasTransparency } from "$lib/core/analyze";
|
||||||
import { debounce } from "$lib/core/debounce";
|
import { debounce } from "$lib/core/debounce";
|
||||||
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
||||||
import { decodeFile, downloadBlob, encode } from "$lib/core/io";
|
import {
|
||||||
|
decodeFile,
|
||||||
|
downloadBlob,
|
||||||
|
encode,
|
||||||
|
outputFormatByMime,
|
||||||
|
type OutputMime,
|
||||||
|
} from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { execute } from "$lib/executor";
|
import { execute } from "$lib/executor";
|
||||||
import {
|
import {
|
||||||
@@ -12,14 +21,33 @@
|
|||||||
verdictText,
|
verdictText,
|
||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, Page, Tool } from "$lib/registry";
|
import {
|
||||||
|
createChain,
|
||||||
|
insertStep,
|
||||||
|
loadChain,
|
||||||
|
moveStep,
|
||||||
|
removeStep,
|
||||||
|
saveChain,
|
||||||
|
type ChainStep,
|
||||||
|
} from "$lib/pipeline.svelte";
|
||||||
|
import {
|
||||||
|
getTool,
|
||||||
|
PAGES,
|
||||||
|
type FileResult,
|
||||||
|
type Page,
|
||||||
|
type Tool,
|
||||||
|
type ToolResult,
|
||||||
|
} from "$lib/registry";
|
||||||
import {
|
import {
|
||||||
applySourceDefaults,
|
applySourceDefaults,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
sanitizeSchemaParams,
|
withAspectLock,
|
||||||
|
type Dimension,
|
||||||
type ToolSchema,
|
type ToolSchema,
|
||||||
} from "$lib/registry-schema";
|
} from "$lib/registry-schema";
|
||||||
import { downloadZip } from "$lib/zip";
|
import { downloadZip } from "$lib/zip";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { SvelteMap, SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: Page;
|
page: Page;
|
||||||
@@ -35,9 +63,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const inputMode = $derived(tool.input);
|
const inputMode = $derived(tool.input);
|
||||||
const resultKind = $derived(tool.result ?? "image");
|
|
||||||
|
|
||||||
let values = $state<Record<string, unknown>>({});
|
let steps = $state<ChainStep[]>([]);
|
||||||
|
// Поля, изменённые пользователем (по шагам): при загрузке нового файла им не
|
||||||
|
// даём перезаписаться source-дефолтами. Reset шага снимает пометку.
|
||||||
|
const touchedByStep = new SvelteMap<string, SvelteSet<string>>();
|
||||||
|
// Последняя правленая ось dimension-поля — ведущая при lockAspect.
|
||||||
|
const lastAxis: Record<string, "width" | "height"> = {};
|
||||||
|
let stepDims = $state<(Dimension | undefined)[]>([]);
|
||||||
let source = $state<PixelImage | null>(null);
|
let source = $state<PixelImage | null>(null);
|
||||||
let result = $state<PixelImage | null>(null);
|
let result = $state<PixelImage | null>(null);
|
||||||
let fileResult = $state<FileResult | null>(null);
|
let fileResult = $state<FileResult | null>(null);
|
||||||
@@ -48,7 +81,44 @@
|
|||||||
);
|
);
|
||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let displayError = $state<DisplayError | null>(null);
|
let displayError = $state<DisplayError | null>(null);
|
||||||
let started = $state(false);
|
let initedFor = $state("");
|
||||||
|
let format = $state<OutputMime>("image/png");
|
||||||
|
// Качество lossy-форматов для инструментов без quality-параметра в схеме.
|
||||||
|
let formatQuality = $state<Record<string, number>>({});
|
||||||
|
let dragFrom = $state<number | null>(null);
|
||||||
|
|
||||||
|
const lastTool = $derived(
|
||||||
|
(steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ??
|
||||||
|
tool,
|
||||||
|
);
|
||||||
|
const resultKind = $derived(lastTool.result ?? "image");
|
||||||
|
// Цепочку можно продолжить, только если последний шаг отдаёт картинку.
|
||||||
|
const canExtend = $derived((lastTool.result ?? "image") === "image");
|
||||||
|
|
||||||
|
const alphaLoss = $derived(
|
||||||
|
resultKind === "image" &&
|
||||||
|
result !== null &&
|
||||||
|
!outputFormatByMime(format).supportsAlpha &&
|
||||||
|
hasTransparency(result),
|
||||||
|
);
|
||||||
|
// Если в схеме последнего шага есть quality-параметр (convert-инструменты),
|
||||||
|
// он — единый источник качества; dropdown редактирует его же.
|
||||||
|
const qualityParamId = $derived(lastTool.output?.qualityParamId);
|
||||||
|
const lastParams = $derived(steps.at(-1)?.params);
|
||||||
|
const currentQuality = $derived.by(() => {
|
||||||
|
const setting = outputFormatByMime(format).settings?.quality;
|
||||||
|
if (!setting) return undefined;
|
||||||
|
if (qualityParamId && lastParams) return Number(lastParams[qualityParamId]);
|
||||||
|
return formatQuality[format] ?? setting.default;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setFormatQuality(q: number) {
|
||||||
|
if (qualityParamId) {
|
||||||
|
setStepValue(steps.length - 1, qualityParamId, q);
|
||||||
|
} else {
|
||||||
|
formatQuality = { ...formatQuality, [format]: q };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errorText = $derived.by(() => {
|
const errorText = $derived.by(() => {
|
||||||
const current = displayError;
|
const current = displayError;
|
||||||
@@ -60,20 +130,30 @@
|
|||||||
|
|
||||||
const debouncedRun = debounce(() => run(), 200);
|
const debouncedRun = debounce(() => run(), 200);
|
||||||
|
|
||||||
|
function touchedFor(key: string): SvelteSet<string> {
|
||||||
|
let set = touchedByStep.get(key);
|
||||||
|
if (!set) {
|
||||||
|
set = new SvelteSet();
|
||||||
|
touchedByStep.set(key, set);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolSchemaOf(step: ChainStep) {
|
||||||
|
return getTool(step.id)?.schema as ToolSchema<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepTitle(toolId: string): string {
|
||||||
|
const owner = PAGES.find((p) => p.steps[0].id === toolId);
|
||||||
|
return owner ? pageTitle(owner) : toolId;
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
if (!schema || started) return;
|
if (!schema || page.slug === initedFor) return;
|
||||||
started = true;
|
initedFor = page.slug;
|
||||||
values = defaultSchemaParams(schema);
|
steps = loadChain(page.slug) ?? createChain(page);
|
||||||
}
|
format = lastTool.output?.mime ?? "image/png";
|
||||||
|
source = null;
|
||||||
function setValue(id: string, value: unknown) {
|
|
||||||
values = { ...values, [id]: value };
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
values = schema
|
|
||||||
? defaultSchemaParams(schema, { source: source ?? undefined })
|
|
||||||
: {};
|
|
||||||
result = null;
|
result = null;
|
||||||
fileResult = null;
|
fileResult = null;
|
||||||
textResult = null;
|
textResult = null;
|
||||||
@@ -81,53 +161,171 @@
|
|||||||
displayError = null;
|
displayError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStepValue(
|
||||||
|
index: number,
|
||||||
|
id: string,
|
||||||
|
value: unknown,
|
||||||
|
axis?: "width" | "height" | "both",
|
||||||
|
) {
|
||||||
|
const step = steps[index];
|
||||||
|
if (!step) return;
|
||||||
|
touchedFor(step.key).add(id);
|
||||||
|
const axisKey = `${step.key}:${id}`;
|
||||||
|
if (axis) lastAxis[axisKey] = axis === "both" ? "width" : axis;
|
||||||
|
const next: Record<string, unknown> = { ...step.params, [id]: value };
|
||||||
|
const stepSchema = toolSchemaOf(step);
|
||||||
|
const spec = stepSchema?.fields[id]?.spec;
|
||||||
|
// Аспект считается от входа шага: для первого — исходник, для остальных —
|
||||||
|
// результат предыдущего шага (размеры известны после прогона).
|
||||||
|
const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1];
|
||||||
|
if (dims && stepSchema && spec) {
|
||||||
|
const aspect = dims.width / dims.height;
|
||||||
|
if (
|
||||||
|
spec.kind === "dimension" &&
|
||||||
|
spec.lockAspectWith &&
|
||||||
|
axis !== "both" &&
|
||||||
|
next[spec.lockAspectWith] === true
|
||||||
|
) {
|
||||||
|
next[id] = withAspectLock(
|
||||||
|
value as Dimension,
|
||||||
|
lastAxis[axisKey] ?? "width",
|
||||||
|
aspect,
|
||||||
|
);
|
||||||
|
} else if (spec.kind === "checkbox" && value === true) {
|
||||||
|
// Включили lockAspect — сразу подгоняем привязанные поля под аспект.
|
||||||
|
for (const [fid, f] of Object.entries(stepSchema.fields)) {
|
||||||
|
const fs = f.spec;
|
||||||
|
if (fs.kind === "dimension" && fs.lockAspectWith === id) {
|
||||||
|
next[fid] = withAspectLock(
|
||||||
|
next[fid] as Dimension,
|
||||||
|
lastAxis[`${step.key}:${fid}`] ?? "width",
|
||||||
|
aspect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps = steps.with(index, { ...step, params: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStep(index: number) {
|
||||||
|
const step = steps[index];
|
||||||
|
if (!step) return;
|
||||||
|
touchedFor(step.key).clear();
|
||||||
|
const stepSchema = toolSchemaOf(step);
|
||||||
|
const dims = index === 0 ? (source ?? undefined) : stepDims[index - 1];
|
||||||
|
const params = stepSchema
|
||||||
|
? defaultSchemaParams(stepSchema, { source: dims })
|
||||||
|
: {};
|
||||||
|
steps = steps.with(index, { ...step, params });
|
||||||
|
result = null;
|
||||||
|
fileResult = null;
|
||||||
|
textResult = null;
|
||||||
|
verdictVars = undefined;
|
||||||
|
displayError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStepAt(index: number, toolId: string) {
|
||||||
|
steps = insertStep(steps, index, toolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStepAt(key: string) {
|
||||||
|
steps = removeStep(steps, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStepDrop(e: DragEvent, to: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragFrom !== null) steps = moveStep(steps, dragFrom, to);
|
||||||
|
dragFrom = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
displayError = null;
|
displayError = null;
|
||||||
try {
|
try {
|
||||||
const decoded = await decodeFile(file);
|
const decoded = await decodeFile(file);
|
||||||
source = decoded;
|
source = decoded;
|
||||||
if (schema) {
|
const first = steps[0];
|
||||||
values = applySourceDefaults(schema, values, { source: decoded });
|
const firstSchema = first ? toolSchemaOf(first) : undefined;
|
||||||
|
if (first && firstSchema) {
|
||||||
|
steps = steps.with(0, {
|
||||||
|
...first,
|
||||||
|
params: applySourceDefaults(
|
||||||
|
firstSchema,
|
||||||
|
first.params,
|
||||||
|
{ source: decoded },
|
||||||
|
touchedFor(first.key),
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError = toDisplayError(e);
|
displayError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assignResult(out: ToolResult) {
|
||||||
|
if (resultKind === "image") {
|
||||||
|
result = out as PixelImage;
|
||||||
|
textResult = null;
|
||||||
|
fileResult = null;
|
||||||
|
} else if (resultKind === "files") {
|
||||||
|
fileResult = out as FileResult;
|
||||||
|
result = null;
|
||||||
|
textResult = null;
|
||||||
|
} else {
|
||||||
|
if (typeof out === "string") {
|
||||||
|
textResult = out;
|
||||||
|
verdictVars = undefined;
|
||||||
|
} else if (out && "key" in out) {
|
||||||
|
textResult = out.key;
|
||||||
|
verdictVars = out.vars;
|
||||||
|
} else {
|
||||||
|
textResult = null;
|
||||||
|
verdictVars = undefined;
|
||||||
|
}
|
||||||
|
result = null;
|
||||||
|
fileResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!schema) return;
|
if (!schema || steps.length === 0) return;
|
||||||
displayError = null;
|
displayError = null;
|
||||||
running = true;
|
running = true;
|
||||||
try {
|
try {
|
||||||
const out = await execute(tool, {
|
let current: PixelImage | undefined = source ?? undefined;
|
||||||
params: sanitizeSchemaParams(schema, values, {
|
const dims: (Dimension | undefined)[] = [];
|
||||||
source: source ?? undefined,
|
let out: ToolResult = "";
|
||||||
}),
|
for (let i = 0; i < steps.length; i++) {
|
||||||
source: source ?? undefined,
|
const stepTool = getTool(steps[i].id);
|
||||||
text: textSource || undefined,
|
if (!stepTool) continue;
|
||||||
});
|
try {
|
||||||
if (resultKind === "image") {
|
out = await execute(stepTool, {
|
||||||
result = out as PixelImage;
|
params: steps[i].params,
|
||||||
textResult = null;
|
source: stepTool.input === "image" ? current : undefined,
|
||||||
fileResult = null;
|
text:
|
||||||
} else if (resultKind === "files") {
|
stepTool.input === "text" ? textSource || undefined : undefined,
|
||||||
fileResult = out as FileResult;
|
});
|
||||||
result = null;
|
} catch (e) {
|
||||||
textResult = null;
|
const base = toDisplayError(e);
|
||||||
} else {
|
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
|
||||||
if (typeof out === "string") {
|
throw new Error(
|
||||||
textResult = out;
|
t("chain.stepError", {
|
||||||
verdictVars = undefined;
|
n: i + 1,
|
||||||
} else if (out && "key" in out) {
|
title: stepTitle(stepTool.id),
|
||||||
textResult = out.key;
|
msg,
|
||||||
verdictVars = out.vars;
|
}),
|
||||||
} else {
|
{ cause: e },
|
||||||
textResult = null;
|
);
|
||||||
verdictVars = undefined;
|
}
|
||||||
|
if (out && typeof out === "object" && "data" in out) {
|
||||||
|
current = out as PixelImage;
|
||||||
|
dims[i] = { width: current.width, height: current.height };
|
||||||
|
} else {
|
||||||
|
current = undefined;
|
||||||
}
|
}
|
||||||
result = null;
|
|
||||||
fileResult = null;
|
|
||||||
}
|
}
|
||||||
|
stepDims = dims;
|
||||||
|
assignResult(out);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError = toDisplayError(e);
|
displayError = toDisplayError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,12 +344,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const out = tool.output;
|
const out = outputFormatByMime(format);
|
||||||
const quality = out?.qualityParamId
|
const quality =
|
||||||
? Number(values[out.qualityParamId]) / 100
|
currentQuality !== undefined ? currentQuality / 100 : undefined;
|
||||||
: undefined;
|
const blob = await encode(result, out.mime, quality);
|
||||||
const blob = await encode(result, out?.mime ?? "image/png", quality);
|
downloadBlob(blob, `${page.slug}.${out.ext}`);
|
||||||
downloadBlob(blob, `${page.slug}.${out?.ext ?? "png"}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError = toDisplayError(e);
|
displayError = toDisplayError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,7 +360,7 @@
|
|||||||
if (!textResult) return;
|
if (!textResult) return;
|
||||||
const copyValue =
|
const copyValue =
|
||||||
resultKind === "verdict"
|
resultKind === "verdict"
|
||||||
? verdictText(tool.id, textResult, verdictVars)
|
? verdictText(lastTool.id, textResult, verdictVars)
|
||||||
: textResult;
|
: textResult;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(copyValue);
|
await navigator.clipboard.writeText(copyValue);
|
||||||
@@ -186,11 +383,37 @@
|
|||||||
init();
|
init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Цепочка персистится по slug страницы; сохраняем после инициализации.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!started) return;
|
if (!initedFor) return;
|
||||||
|
void steps;
|
||||||
|
saveChain(initedFor, steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (inputMode !== "image") return;
|
||||||
|
function onPaste(e: ClipboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest("input, textarea, [contenteditable]")) return;
|
||||||
|
for (const item of e.clipboardData?.items ?? []) {
|
||||||
|
if (item.kind !== "file" || !item.type.startsWith("image/")) continue;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleFile(file);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("paste", onPaste);
|
||||||
|
return () => window.removeEventListener("paste", onPaste);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!initedFor) return;
|
||||||
if (inputMode === "image" && !source) return;
|
if (inputMode === "image" && !source) return;
|
||||||
if (inputMode === "text" && !textSource.trim()) return;
|
if (inputMode === "text" && !textSource.trim()) return;
|
||||||
void values;
|
void steps;
|
||||||
void source;
|
void source;
|
||||||
debouncedRun();
|
debouncedRun();
|
||||||
return () => debouncedRun.cancel();
|
return () => debouncedRun.cancel();
|
||||||
@@ -215,23 +438,49 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<section class="panel settings">
|
<section class="settings">
|
||||||
<div class="panel-head">
|
{#if canExtend}
|
||||||
<span class="label">{t("paramsCard.toolSettings")}</span>
|
<AddStepButton onadd={(id) => addStepAt(0, id)} />
|
||||||
<strong>{t("paramsCard.configureOutput")}</strong>
|
{/if}
|
||||||
</div>
|
{#each steps as step, i (step.key)}
|
||||||
<SchemaFields
|
<StepCard
|
||||||
{schema}
|
index={i + 1}
|
||||||
{values}
|
title={stepTitle(step.id)}
|
||||||
toolId={tool.id}
|
draggable
|
||||||
onchange={setValue}
|
onremove={steps.length > 1
|
||||||
onreset={reset}
|
? () => removeStepAt(step.key)
|
||||||
/>
|
: undefined}
|
||||||
|
ondragstart={(e) => {
|
||||||
|
dragFrom = i;
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", String(i));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
|
}}
|
||||||
|
ondrop={(e) => onStepDrop(e, i)}
|
||||||
|
ondragend={() => (dragFrom = null)}
|
||||||
|
>
|
||||||
|
<SchemaFields
|
||||||
|
schema={toolSchemaOf(step)}
|
||||||
|
values={step.params}
|
||||||
|
toolId={step.id}
|
||||||
|
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
|
||||||
|
onreset={() => resetStep(i)}
|
||||||
|
/>
|
||||||
|
</StepCard>
|
||||||
|
{#if canExtend}
|
||||||
|
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<SchemaPreview
|
<SchemaPreview
|
||||||
toolId={tool.id}
|
toolId={lastTool.id}
|
||||||
{source}
|
{source}
|
||||||
{result}
|
{result}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
@@ -242,6 +491,11 @@
|
|||||||
{resultKind}
|
{resultKind}
|
||||||
{running}
|
{running}
|
||||||
error={errorText}
|
error={errorText}
|
||||||
|
{format}
|
||||||
|
quality={currentQuality}
|
||||||
|
{alphaLoss}
|
||||||
|
onformat={(v) => (format = v)}
|
||||||
|
onquality={setFormatQuality}
|
||||||
onupload={handleFile}
|
onupload={handleFile}
|
||||||
ontextsource={(textValue) => {
|
ontextsource={(textValue) => {
|
||||||
textSource = textValue;
|
textSource = textValue;
|
||||||
@@ -276,15 +530,6 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.label {
|
|
||||||
font: var(--font-size-s) var(--font-mono);
|
|
||||||
letter-spacing: var(--space-text-l);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
color: var(--color-main);
|
|
||||||
}
|
|
||||||
.lede {
|
.lede {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: var(--space-m) 0 0;
|
margin: var(--space-m) 0 0;
|
||||||
@@ -307,19 +552,6 @@
|
|||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
padding: var(--space-xl);
|
padding: var(--space-xl);
|
||||||
}
|
}
|
||||||
.panel-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--space-m);
|
|
||||||
padding-bottom: var(--space-l);
|
|
||||||
border-bottom: var(--size-border) solid var(--color-border);
|
|
||||||
margin-bottom: var(--space-xl);
|
|
||||||
}
|
|
||||||
.panel-head strong {
|
|
||||||
font-size: var(--font-size-l);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.no-schema {
|
.no-schema {
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ describe("buildSchemaPreviewModel", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
sourceValue: "—",
|
sourceValue: "—",
|
||||||
resultValue: "2 × 3 px",
|
resultValue: "2 × 3 px",
|
||||||
formatValue: "PNG",
|
|
||||||
hasResult: true,
|
hasResult: true,
|
||||||
});
|
});
|
||||||
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
|
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
|
||||||
@@ -75,7 +74,6 @@ describe("buildSchemaPreviewModel", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
sourceValue: "—",
|
sourceValue: "—",
|
||||||
resultValue: "1 parts",
|
resultValue: "1 parts",
|
||||||
formatValue: "ZIP (PNG)",
|
|
||||||
hasResult: true,
|
hasResult: true,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -96,7 +94,6 @@ describe("buildSchemaPreviewModel", () => {
|
|||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
resultValue: "Text",
|
resultValue: "Text",
|
||||||
formatValue: "PNG",
|
|
||||||
hasResult: true,
|
hasResult: true,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export type SchemaPreviewModelInput = {
|
|||||||
export type SchemaPreviewModel = {
|
export type SchemaPreviewModel = {
|
||||||
sourceValue: string;
|
sourceValue: string;
|
||||||
resultValue: string;
|
resultValue: string;
|
||||||
formatValue: string;
|
|
||||||
hasResult: boolean;
|
hasResult: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export function buildSchemaPreviewModel(
|
|||||||
return {
|
return {
|
||||||
sourceValue,
|
sourceValue,
|
||||||
resultValue,
|
resultValue,
|
||||||
formatValue: resultKind === "files" ? "ZIP (PNG)" : "PNG",
|
|
||||||
hasResult:
|
hasResult:
|
||||||
resultKind === "image"
|
resultKind === "image"
|
||||||
? Boolean(result)
|
? Boolean(result)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check } from "@lucide/svelte";
|
import { Check } from "@lucide/svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
const ToneVariantDefine = {
|
const ToneVariantDefine = {
|
||||||
DEFAULT: "default",
|
DEFAULT: "default",
|
||||||
@@ -15,28 +17,43 @@
|
|||||||
DEFAULT: "default",
|
DEFAULT: "default",
|
||||||
OUTLINE: "outline",
|
OUTLINE: "outline",
|
||||||
CLEAR: "clear",
|
CLEAR: "clear",
|
||||||
|
TINT: "tint",
|
||||||
} as const;
|
} as const;
|
||||||
type BadgeVariant =
|
type BadgeVariant =
|
||||||
(typeof BadgeVariantDefine)[keyof typeof BadgeVariantDefine];
|
(typeof BadgeVariantDefine)[keyof typeof BadgeVariantDefine];
|
||||||
|
|
||||||
interface Props {
|
const BadgeSizeDefine = {
|
||||||
|
S: "s",
|
||||||
|
M: "m",
|
||||||
|
} as const;
|
||||||
|
type BadgeSize = (typeof BadgeSizeDefine)[keyof typeof BadgeSizeDefine];
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLSpanElement> {
|
||||||
tone?: ToneVariant;
|
tone?: ToneVariant;
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
|
size?: BadgeSize;
|
||||||
check?: boolean;
|
check?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tone = ToneVariantDefine.DEFAULT,
|
tone = ToneVariantDefine.DEFAULT,
|
||||||
variant = BadgeVariantDefine.DEFAULT,
|
variant = BadgeVariantDefine.DEFAULT,
|
||||||
|
size = BadgeSizeDefine.S,
|
||||||
check = false,
|
check = false,
|
||||||
label = "",
|
label = "",
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="badge badge-tone--{tone} badge-variant--{variant}">
|
<span
|
||||||
|
class="badge badge-tone--{tone} badge-variant--{variant} badge-size--{size}"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{#if check}<Check size={12} />{/if}
|
{#if check}<Check size={12} />{/if}
|
||||||
{label}
|
{#if children}{@render children()}{:else}{label}{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -94,4 +111,18 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--color-badge-tint);
|
color: var(--color-badge-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-variant--tint {
|
||||||
|
background-color: var(--color-badge-tint);
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-badge-main);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
color: var(--color-badge-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-size--m {
|
||||||
|
gap: var(--space-l);
|
||||||
|
padding: var(--space-m) var(--space-xl);
|
||||||
|
font-size: var(--font-size-m);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,45 @@ import { type PixelImage } from "./types";
|
|||||||
export type OutputMime =
|
export type OutputMime =
|
||||||
"image/png" | "image/jpeg" | "image/webp" | "image/bmp";
|
"image/png" | "image/jpeg" | "image/webp" | "image/bmp";
|
||||||
|
|
||||||
|
export interface OutputFormatOption {
|
||||||
|
mime: OutputMime;
|
||||||
|
ext: string;
|
||||||
|
label: string;
|
||||||
|
supportsAlpha: boolean;
|
||||||
|
/** Точные настройки формата в dropdown кнопки Download (расширяемо). */
|
||||||
|
settings?: {
|
||||||
|
quality?: { min: number; max: number; step: number; default: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUALITY_SETTING = { min: 1, max: 100, step: 1, default: 90 } as const;
|
||||||
|
|
||||||
|
/** Варианты формата в кнопке Download (селектор рядом с ней). */
|
||||||
|
export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [
|
||||||
|
{ mime: "image/png", ext: "png", label: "PNG", supportsAlpha: true },
|
||||||
|
{
|
||||||
|
mime: "image/jpeg",
|
||||||
|
ext: "jpg",
|
||||||
|
label: "JPG",
|
||||||
|
supportsAlpha: false,
|
||||||
|
settings: { quality: QUALITY_SETTING },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mime: "image/webp",
|
||||||
|
ext: "webp",
|
||||||
|
label: "WebP",
|
||||||
|
supportsAlpha: true,
|
||||||
|
settings: { quality: QUALITY_SETTING },
|
||||||
|
},
|
||||||
|
{ mime: "image/bmp", ext: "bmp", label: "BMP", supportsAlpha: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function outputFormatByMime(mime: OutputMime): OutputFormatOption {
|
||||||
|
const found = OUTPUT_FORMATS.find((f) => f.mime === mime);
|
||||||
|
if (!found) throw new ToolError("errors.encodeUnsupported", { mime });
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES =
|
export const ACCEPTED_IMAGE_TYPES =
|
||||||
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon";
|
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon";
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const en: Dict = {
|
|||||||
stepError: "Step {n} ({title}): {msg}",
|
stepError: "Step {n} ({title}): {msg}",
|
||||||
},
|
},
|
||||||
chain: {
|
chain: {
|
||||||
stepLabel: "Step {n}: {title}",
|
addStep: "Add step",
|
||||||
inputLegend: "Input",
|
inputLegend: "Input",
|
||||||
resultLegend: "Result",
|
resultLegend: "Result",
|
||||||
paramsLegend: "Parameters",
|
paramsLegend: "Parameters",
|
||||||
@@ -75,13 +75,10 @@ export const en: Dict = {
|
|||||||
result: "Result",
|
result: "Result",
|
||||||
previewPanel: "Preview",
|
previewPanel: "Preview",
|
||||||
parts: "parts",
|
parts: "parts",
|
||||||
format: "Format",
|
|
||||||
alt: "Result image",
|
alt: "Result image",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
toolSettings: "Tool settings",
|
|
||||||
noParams: "This tool has no parameters — the result is ready as is.",
|
noParams: "This tool has no parameters — the result is ready as is.",
|
||||||
configureOutput: "Configure output",
|
|
||||||
noSchema: "This tool has no schema yet.",
|
noSchema: "This tool has no schema yet.",
|
||||||
},
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
@@ -100,7 +97,10 @@ export const en: Dict = {
|
|||||||
generate: "Generate",
|
generate: "Generate",
|
||||||
generating: "Generating…",
|
generating: "Generating…",
|
||||||
openImage: "Open image",
|
openImage: "Open image",
|
||||||
downloadResult: "Download result",
|
download: "Download",
|
||||||
|
downloadFormat: "Download {format}",
|
||||||
|
formatAria: "Choose download format",
|
||||||
|
alphaLoss: "Result has transparency — {format} will flatten it",
|
||||||
},
|
},
|
||||||
textSource: {
|
textSource: {
|
||||||
placeholder: "Paste base64 / hex / bytes…",
|
placeholder: "Paste base64 / hex / bytes…",
|
||||||
@@ -122,7 +122,7 @@ export const en: Dict = {
|
|||||||
pickDefault: "Drop an image here or click to choose a file",
|
pickDefault: "Drop an image here or click to choose a file",
|
||||||
overlayDefault: "Release the file to replace the image",
|
overlayDefault: "Release the file to replace the image",
|
||||||
pickTitle: "Drop a PNG here",
|
pickTitle: "Drop a PNG here",
|
||||||
pickHint: "or click to browse — processed locally",
|
pickHint: "or click / paste (Ctrl+V) — processed locally",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: "Find a tool…",
|
placeholder: "Find a tool…",
|
||||||
@@ -140,7 +140,6 @@ export const en: Dict = {
|
|||||||
overlayTitle: "Watermark",
|
overlayTitle: "Watermark",
|
||||||
overlayDrop: "Drop a watermark PNG or click",
|
overlayDrop: "Drop a watermark PNG or click",
|
||||||
overlayRemove: "Remove watermark",
|
overlayRemove: "Remove watermark",
|
||||||
autoUpdate: "updates automatically",
|
|
||||||
width: "Width",
|
width: "Width",
|
||||||
height: "Height",
|
height: "Height",
|
||||||
from: "From",
|
from: "From",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const ru: Dict = {
|
|||||||
stepError: "Шаг {n} ({title}): {msg}",
|
stepError: "Шаг {n} ({title}): {msg}",
|
||||||
},
|
},
|
||||||
chain: {
|
chain: {
|
||||||
stepLabel: "Шаг {n}: {title}",
|
addStep: "Добавить шаг",
|
||||||
inputLegend: "Вход",
|
inputLegend: "Вход",
|
||||||
resultLegend: "Результат",
|
resultLegend: "Результат",
|
||||||
paramsLegend: "Параметры",
|
paramsLegend: "Параметры",
|
||||||
@@ -76,13 +76,10 @@ export const ru: Dict = {
|
|||||||
result: "Результат",
|
result: "Результат",
|
||||||
previewPanel: "Предпросмотр",
|
previewPanel: "Предпросмотр",
|
||||||
parts: "частей",
|
parts: "частей",
|
||||||
format: "Формат",
|
|
||||||
alt: "Изображение-результат",
|
alt: "Изображение-результат",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
toolSettings: "Настройки инструмента",
|
|
||||||
noParams: "У этого инструмента нет параметров — результат уже готов.",
|
noParams: "У этого инструмента нет параметров — результат уже готов.",
|
||||||
configureOutput: "Настроить вывод",
|
|
||||||
noSchema: "У этого инструмента пока нет схемы.",
|
noSchema: "У этого инструмента пока нет схемы.",
|
||||||
},
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
@@ -101,7 +98,10 @@ export const ru: Dict = {
|
|||||||
generate: "Сгенерировать",
|
generate: "Сгенерировать",
|
||||||
generating: "Генерация…",
|
generating: "Генерация…",
|
||||||
openImage: "Открыть изображение",
|
openImage: "Открыть изображение",
|
||||||
downloadResult: "Скачать результат",
|
download: "Скачать",
|
||||||
|
downloadFormat: "Скачать {format}",
|
||||||
|
formatAria: "Выбор формата скачивания",
|
||||||
|
alphaLoss: "В результате есть прозрачность — {format} её уберёт",
|
||||||
},
|
},
|
||||||
textSource: {
|
textSource: {
|
||||||
placeholder: "Вставьте base64 / hex / байты…",
|
placeholder: "Вставьте base64 / hex / байты…",
|
||||||
@@ -123,7 +123,7 @@ export const ru: Dict = {
|
|||||||
pickDefault: "Перетащите изображение сюда или нажмите, чтобы выбрать файл",
|
pickDefault: "Перетащите изображение сюда или нажмите, чтобы выбрать файл",
|
||||||
overlayDefault: "Отпустите файл, чтобы заменить изображение",
|
overlayDefault: "Отпустите файл, чтобы заменить изображение",
|
||||||
pickTitle: "Перетащите PNG сюда",
|
pickTitle: "Перетащите PNG сюда",
|
||||||
pickHint: "или нажмите, чтобы выбрать — всё локально",
|
pickHint: "или нажмите / вставьте (Ctrl+V) — всё локально",
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
placeholder: "Найдите инструмент…",
|
placeholder: "Найдите инструмент…",
|
||||||
@@ -141,7 +141,6 @@ export const ru: Dict = {
|
|||||||
overlayTitle: "Знак",
|
overlayTitle: "Знак",
|
||||||
overlayDrop: "Перетащите PNG-знак или нажмите",
|
overlayDrop: "Перетащите PNG-знак или нажмите",
|
||||||
overlayRemove: "Убрать знак",
|
overlayRemove: "Убрать знак",
|
||||||
autoUpdate: "обновляется автоматически",
|
|
||||||
width: "Ширина",
|
width: "Ширина",
|
||||||
height: "Высота",
|
height: "Высота",
|
||||||
from: "От",
|
from: "От",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ describe("Смоук §6 i18n (новый UI)", () => {
|
|||||||
],
|
],
|
||||||
["chain.inputLegend", "Вход", "Input"],
|
["chain.inputLegend", "Вход", "Input"],
|
||||||
["resultCard.nextTool", "Следующий инструмент", "Next tool"],
|
["resultCard.nextTool", "Следующий инструмент", "Next tool"],
|
||||||
["paramsCard.toolSettings", "Настройки инструмента", "Tool settings"],
|
|
||||||
["download.busy", "Готовим файл", "Preparing file"],
|
["download.busy", "Готовим файл", "Preparing file"],
|
||||||
["dropZone.pickDefault", "Перетащите изображение", "Drop an image"],
|
["dropZone.pickDefault", "Перетащите изображение", "Drop an image"],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { getTool, PAGES, type Page } from "./registry";
|
||||||
|
import { defaultSchemaParams, sanitizeSchemaParams } from "./registry-schema";
|
||||||
|
|
||||||
|
export interface ChainStep {
|
||||||
|
/** Стабильный ключ для keyed each и drag-n-drop (переживает reorder). */
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAIN_VERSION = 1;
|
||||||
|
|
||||||
|
function uid(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(slug: string): string {
|
||||||
|
return `pipeline:${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storage(): Storage | null {
|
||||||
|
return typeof localStorage === "undefined" ? null : localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Инструмент можно вставить в середину цепочки: принимает и отдаёт картинку. */
|
||||||
|
export function isChainable(toolId: string): boolean {
|
||||||
|
const tool = getTool(toolId);
|
||||||
|
return (
|
||||||
|
tool !== undefined &&
|
||||||
|
tool.input === "image" &&
|
||||||
|
(tool.result ?? "image") === "image"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Страницы, чьи инструменты можно добавлять шагами (для picker'а). */
|
||||||
|
export function chainablePages(): Page[] {
|
||||||
|
return PAGES.filter((p) => isChainable(p.steps[0].id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStep(id: string): ChainStep | null {
|
||||||
|
const tool = getTool(id);
|
||||||
|
if (!tool) return null;
|
||||||
|
return { key: uid(), id, params: defaultSchemaParams(tool.schema) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChain(page: Page): ChainStep[] {
|
||||||
|
const steps: ChainStep[] = [];
|
||||||
|
for (const s of page.steps) {
|
||||||
|
const tool = getTool(s.id);
|
||||||
|
if (!tool) continue;
|
||||||
|
steps.push({
|
||||||
|
key: uid(),
|
||||||
|
id: s.id,
|
||||||
|
params: sanitizeSchemaParams(tool.schema, s.params ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Валидация сохранённой цепочки; невалидные данные → null (дефолт страницы). */
|
||||||
|
export function parseChain(data: unknown): ChainStep[] | null {
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== "object" ||
|
||||||
|
(data as { version?: unknown }).version !== CHAIN_VERSION ||
|
||||||
|
!Array.isArray((data as { steps?: unknown }).steps)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const out: ChainStep[] = [];
|
||||||
|
for (const raw of (data as { steps: unknown[] }).steps) {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const { id, params } = raw as { id?: unknown; params?: unknown };
|
||||||
|
if (typeof id !== "string") return null;
|
||||||
|
const tool = getTool(id);
|
||||||
|
if (!tool) return null;
|
||||||
|
out.push({
|
||||||
|
key: uid(),
|
||||||
|
id,
|
||||||
|
params: sanitizeSchemaParams(
|
||||||
|
tool.schema,
|
||||||
|
(params ?? {}) as Record<string, unknown>,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.length > 0 ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadChain(slug: string): ChainStep[] | null {
|
||||||
|
const raw = storage()?.getItem(storageKey(slug));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return parseChain(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveChain(slug: string, steps: ChainStep[]): void {
|
||||||
|
storage()?.setItem(
|
||||||
|
storageKey(slug),
|
||||||
|
JSON.stringify({
|
||||||
|
version: CHAIN_VERSION,
|
||||||
|
steps: steps.map((s) => ({ id: s.id, params: s.params })),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertStep(
|
||||||
|
steps: ChainStep[],
|
||||||
|
index: number,
|
||||||
|
id: string,
|
||||||
|
): ChainStep[] {
|
||||||
|
const step = createStep(id);
|
||||||
|
if (!step) return steps;
|
||||||
|
return [...steps.slice(0, index), step, ...steps.slice(index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStep(steps: ChainStep[], key: string): ChainStep[] {
|
||||||
|
if (steps.length <= 1) return steps;
|
||||||
|
return steps.filter((s) => s.key !== key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveStep(
|
||||||
|
steps: ChainStep[],
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
): ChainStep[] {
|
||||||
|
if (
|
||||||
|
from === to ||
|
||||||
|
from < 0 ||
|
||||||
|
to < 0 ||
|
||||||
|
from >= steps.length ||
|
||||||
|
to >= steps.length
|
||||||
|
) {
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
const next = [...steps];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CHAIN_VERSION,
|
||||||
|
chainablePages,
|
||||||
|
createChain,
|
||||||
|
insertStep,
|
||||||
|
isChainable,
|
||||||
|
moveStep,
|
||||||
|
parseChain,
|
||||||
|
removeStep,
|
||||||
|
} from "./pipeline.svelte";
|
||||||
|
import { getPageBySlug } from "./registry";
|
||||||
|
|
||||||
|
const cropPage = getPageBySlug("crop-png")!;
|
||||||
|
|
||||||
|
describe("pipeline: цепочка шагов", () => {
|
||||||
|
it("createChain собирает шаги из страницы с дефолтами схемы", () => {
|
||||||
|
const chain = createChain(cropPage);
|
||||||
|
expect(chain).toHaveLength(1);
|
||||||
|
expect(chain[0].id).toBe("crop");
|
||||||
|
expect(chain[0].params).toMatchObject({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isChainable: image→image можно, терминальные и text-вход нельзя", () => {
|
||||||
|
expect(isChainable("resize")).toBe(true);
|
||||||
|
expect(isChainable("to-base64")).toBe(false); // result: text
|
||||||
|
expect(isChainable("split-into-parts")).toBe(false); // result: files
|
||||||
|
expect(isChainable("from-svg")).toBe(false); // input: text
|
||||||
|
expect(isChainable("unknown-tool")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chainablePages содержит только совместимые страницы", () => {
|
||||||
|
const pages = chainablePages();
|
||||||
|
expect(pages.some((p) => p.slug === "resize-png")).toBe(true);
|
||||||
|
expect(pages.some((p) => p.slug === "png-to-base64")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insertStep вставляет шаг в произвольную позицию", () => {
|
||||||
|
const chain = createChain(cropPage);
|
||||||
|
const grown = insertStep(chain, 0, "resize");
|
||||||
|
expect(grown.map((s) => s.id)).toEqual(["resize", "crop"]);
|
||||||
|
expect(grown[0].params).toHaveProperty("keepAspect");
|
||||||
|
// исходная цепочка не мутирует
|
||||||
|
expect(chain).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeStep не удаляет последний шаг", () => {
|
||||||
|
const chain = createChain(cropPage);
|
||||||
|
expect(removeStep(chain, chain[0].key)).toHaveLength(1);
|
||||||
|
const grown = insertStep(chain, 1, "flip");
|
||||||
|
const shrunk = removeStep(grown, grown[0].key);
|
||||||
|
expect(shrunk.map((s) => s.id)).toEqual(["flip"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveStep меняет порядок, ключи шагов сохраняются", () => {
|
||||||
|
let chain = createChain(cropPage);
|
||||||
|
chain = insertStep(chain, 1, "flip");
|
||||||
|
chain = insertStep(chain, 2, "grayscale");
|
||||||
|
const keys = chain.map((s) => s.key);
|
||||||
|
const moved = moveStep(chain, 2, 0);
|
||||||
|
expect(moved.map((s) => s.id)).toEqual(["grayscale", "crop", "flip"]);
|
||||||
|
expect(moved.map((s) => s.key)).toEqual([keys[2], keys[0], keys[1]]);
|
||||||
|
expect(moveStep(chain, 0, 5)).toBe(chain);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pipeline: parseChain (localStorage/импорт)", () => {
|
||||||
|
const valid = {
|
||||||
|
version: CHAIN_VERSION,
|
||||||
|
steps: [
|
||||||
|
{ id: "crop", params: { x: 5 } },
|
||||||
|
{ id: "resize", params: {} },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("валидная цепочка разбирается, параметры санитизируются", () => {
|
||||||
|
const chain = parseChain(valid)!;
|
||||||
|
expect(chain.map((s) => s.id)).toEqual(["crop", "resize"]);
|
||||||
|
expect(chain[0].params).toMatchObject({ x: 5, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["нет версии", { steps: [{ id: "crop" }] }],
|
||||||
|
["другая версия", { version: 99, steps: [{ id: "crop" }] }],
|
||||||
|
["пустой список", { version: CHAIN_VERSION, steps: [] }],
|
||||||
|
[
|
||||||
|
"неизвестный инструмент",
|
||||||
|
{ version: CHAIN_VERSION, steps: [{ id: "nope" }] },
|
||||||
|
],
|
||||||
|
["мусор", "just a string"],
|
||||||
|
])("невалидные данные → null (%s)", (_name, data) => {
|
||||||
|
expect(parseChain(data)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resolveLayoutGroups,
|
resolveLayoutGroups,
|
||||||
sanitizeSchemaParams,
|
sanitizeSchemaParams,
|
||||||
toolSchema,
|
toolSchema,
|
||||||
|
withAspectLock,
|
||||||
type Dimension,
|
type Dimension,
|
||||||
} from "./registry-schema";
|
} from "./registry-schema";
|
||||||
|
|
||||||
@@ -64,6 +65,30 @@ describe("registry-schema: дефолты из схемы", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("registry-schema: withAspectLock", () => {
|
||||||
|
const aspect = 64 / 48;
|
||||||
|
|
||||||
|
it("ведущая ширина — пересчитывается высота", () => {
|
||||||
|
expect(withAspectLock({ width: 32, height: 48 }, "width", aspect)).toEqual({
|
||||||
|
width: 32,
|
||||||
|
height: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ведущая высота — пересчитывается ширина", () => {
|
||||||
|
expect(withAspectLock({ width: 64, height: 96 }, "height", aspect)).toEqual(
|
||||||
|
{ width: 128, height: 96 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("результат не опускается ниже 1", () => {
|
||||||
|
expect(withAspectLock({ width: 1, height: 48 }, "width", aspect)).toEqual({
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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({
|
||||||
@@ -89,6 +114,16 @@ describe("registry-schema: source-aware dimension defaults", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("touched-поля не перезаписываются source-дефолтами", () => {
|
||||||
|
const manual = { size: { width: 5, height: 6 } };
|
||||||
|
expect(
|
||||||
|
applySourceDefaults(sourceSchema, manual, { source }, new Set(["size"])),
|
||||||
|
).toEqual({
|
||||||
|
size: { width: 5, height: 6 },
|
||||||
|
fixed: { width: 10, height: 20 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses source dimensions when sanitizing missing values", () => {
|
it("uses source dimensions when sanitizing missing values", () => {
|
||||||
expect(sanitizeSchemaParams(sourceSchema, {}, { source })).toEqual({
|
expect(sanitizeSchemaParams(sourceSchema, {}, { source })).toEqual({
|
||||||
size: source,
|
size: source,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export interface Dimension {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Единый список тайловых пресетов (квадраты) для dimension-полей. */
|
||||||
|
export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const;
|
||||||
|
|
||||||
export interface DimensionSpec extends FieldSpecBase {
|
export interface DimensionSpec extends FieldSpecBase {
|
||||||
kind: "dimension";
|
kind: "dimension";
|
||||||
/** Общий диапазон для обоих измерений. */
|
/** Общий диапазон для обоих измерений. */
|
||||||
@@ -70,6 +73,13 @@ export interface DimensionSpec extends FieldSpecBase {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
defaultFromSource?: boolean;
|
defaultFromSource?: boolean;
|
||||||
|
/**
|
||||||
|
* Id checkbox-поля «сохранять пропорции»: пока оно включено, правка одной
|
||||||
|
* оси пересчитывает вторую по аспекту текущего исходника.
|
||||||
|
*/
|
||||||
|
lockAspectWith?: string;
|
||||||
|
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
|
||||||
|
presets?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemaContext {
|
export interface SchemaContext {
|
||||||
@@ -276,6 +286,8 @@ export const field = {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
defaultFromSource?: boolean;
|
defaultFromSource?: boolean;
|
||||||
|
lockAspectWith?: string;
|
||||||
|
presets?: boolean;
|
||||||
}): Field<Dimension> => ({
|
}): Field<Dimension> => ({
|
||||||
spec: { kind: "dimension", ...s },
|
spec: { kind: "dimension", ...s },
|
||||||
}),
|
}),
|
||||||
@@ -382,6 +394,21 @@ function clamp(value: number, min?: number, max?: number): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пересчёт при включённом lockAspect: ведущая ось (последняя правка
|
||||||
|
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
|
||||||
|
*/
|
||||||
|
export function withAspectLock(
|
||||||
|
next: Dimension,
|
||||||
|
leading: "width" | "height",
|
||||||
|
aspect: number,
|
||||||
|
): Dimension {
|
||||||
|
if (leading === "width") {
|
||||||
|
return { ...next, height: Math.max(1, Math.round(next.width / aspect)) };
|
||||||
|
}
|
||||||
|
return { ...next, width: Math.max(1, Math.round(next.height * aspect)) };
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDimensionDefaults(
|
function resolveDimensionDefaults(
|
||||||
spec: DimensionSpec,
|
spec: DimensionSpec,
|
||||||
context?: SchemaContext,
|
context?: SchemaContext,
|
||||||
@@ -435,15 +462,22 @@ export function defaultSchemaParams<P>(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пересчитывает дефолты от нового исходника, сохраняя значения пользователя.
|
||||||
|
* Поля из touched (изменённые вручную) не перезаписываются даже если у них
|
||||||
|
* source-дефолт — иначе загрузка нового файла сносила бы настроенные размеры.
|
||||||
|
*/
|
||||||
export function applySourceDefaults<P>(
|
export function applySourceDefaults<P>(
|
||||||
schema: ToolSchema<P>,
|
schema: ToolSchema<P>,
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
context: SchemaContext,
|
context: SchemaContext,
|
||||||
|
touched?: ReadonlySet<string>,
|
||||||
): Record<keyof P, unknown> {
|
): Record<keyof P, unknown> {
|
||||||
const defaults = defaultSchemaParams(schema, context);
|
const defaults = defaultSchemaParams(schema, context);
|
||||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||||
const spec = schema.fields[key].spec;
|
const spec = schema.fields[key].spec;
|
||||||
if (spec.kind === "dimension" && spec.defaultFromSource) continue;
|
const fromSource = spec.kind === "dimension" && spec.defaultFromSource;
|
||||||
|
if (fromSource && !touched?.has(key as string)) continue;
|
||||||
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
||||||
defaults[key] = values[key as string];
|
defaults[key] = values[key as string];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const createEmptySchema = toolSchema<CreateEmptyParams>(
|
|||||||
max: 20000,
|
max: 20000,
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
transparent: field.checkbox({ label: "fields.transparent", default: true }),
|
transparent: field.checkbox({ label: "fields.transparent", default: true }),
|
||||||
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
|
color: field.color({ label: "fields.fillColor", default: "#ffffff" }),
|
||||||
@@ -130,6 +131,7 @@ export const singleColorSchema = toolSchema<SingleColorParams>({
|
|||||||
max: 20000,
|
max: 20000,
|
||||||
width: 256,
|
width: 256,
|
||||||
height: 256,
|
height: 256,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
color: field.color({ label: "fields.color", default: "#ff0000" }),
|
color: field.color({ label: "fields.color", default: "#ff0000" }),
|
||||||
});
|
});
|
||||||
@@ -157,6 +159,7 @@ export const randomNoiseSchema = toolSchema<RandomNoiseParams>({
|
|||||||
max: 5000,
|
max: 5000,
|
||||||
width: 512,
|
width: 512,
|
||||||
height: 512,
|
height: 512,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
seed: field.number({
|
seed: field.number({
|
||||||
label: "fields.seed",
|
label: "fields.seed",
|
||||||
@@ -191,6 +194,7 @@ export const linearGradientSchema = toolSchema<LinearGradientParams>(
|
|||||||
max: 20000,
|
max: 20000,
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
gradient: field.gradient({
|
gradient: field.gradient({
|
||||||
label: "fields.gradient",
|
label: "fields.gradient",
|
||||||
@@ -241,6 +245,7 @@ export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>(
|
|||||||
max: 5000,
|
max: 5000,
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 128,
|
height: 128,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
direction: field.select({
|
direction: field.select({
|
||||||
label: "fields.direction",
|
label: "fields.direction",
|
||||||
@@ -303,6 +308,7 @@ export const randomColorsSchema = toolSchema<RandomColorsParams>(
|
|||||||
max: 5000,
|
max: 5000,
|
||||||
width: 512,
|
width: 512,
|
||||||
height: 512,
|
height: 512,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
blockSize: field.slider({
|
blockSize: field.slider({
|
||||||
label: "fields.blockSize",
|
label: "fields.blockSize",
|
||||||
@@ -357,6 +363,7 @@ export const drawGridSchema = toolSchema<DrawGridParams>(
|
|||||||
max: 5000,
|
max: 5000,
|
||||||
width: 512,
|
width: 512,
|
||||||
height: 512,
|
height: 512,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
cols: field.slider({
|
cols: field.slider({
|
||||||
label: "fields.cols",
|
label: "fields.cols",
|
||||||
@@ -433,6 +440,7 @@ export const placeholderSchema = toolSchema<PlaceholderParams>(
|
|||||||
max: 5000,
|
max: 5000,
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 400,
|
height: 400,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
backgroundColor: field.color({
|
backgroundColor: field.color({
|
||||||
label: "fields.backgroundColor",
|
label: "fields.backgroundColor",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
|
|||||||
max: 20000,
|
max: 20000,
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
transparent: field.checkbox({
|
transparent: field.checkbox({
|
||||||
label: "fields.transparent",
|
label: "fields.transparent",
|
||||||
@@ -130,6 +131,7 @@ export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>(
|
|||||||
max: 20000,
|
max: 20000,
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
anchor: field.select({
|
anchor: field.select({
|
||||||
label: "fields.anchor",
|
label: "fields.anchor",
|
||||||
@@ -185,6 +187,8 @@ export const resizeSchema = toolSchema<ResizeParams>(
|
|||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
defaultFromSource: true,
|
defaultFromSource: true,
|
||||||
|
lockAspectWith: "keepAspect",
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
|
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
|
||||||
},
|
},
|
||||||
@@ -253,6 +257,7 @@ export const cropSchema = toolSchema<CropParams>(
|
|||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
defaultFromSource: true,
|
defaultFromSource: true,
|
||||||
|
presets: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t("home.defaultTitle")}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="catalog-page">
|
<div class="catalog-page">
|
||||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||||
<CatalogToolbar bind:query bind:category />
|
<CatalogToolbar bind:query bind:category />
|
||||||
|
|||||||
@@ -147,6 +147,13 @@
|
|||||||
<Badge tone="warning" label="warning" />
|
<Badge tone="warning" label="warning" />
|
||||||
<Badge tone="info" label="info" />
|
<Badge tone="info" label="info" />
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
|
<SettingGroup label="variants">
|
||||||
|
<Badge tone="success" variant="outline" label="outline" />
|
||||||
|
<Badge tone="success" variant="clear" label="clear" />
|
||||||
|
<Badge tone="success" variant="tint" label="tint" />
|
||||||
|
<Badge tone="danger" variant="tint" label="tint" />
|
||||||
|
<Badge tone="info" variant="tint" size="m" label="tint m" />
|
||||||
|
</SettingGroup>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t("catalog.pageTitle")}</title>
|
||||||
|
<meta name="description" content={t("catalog.metaDescription")} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="catalog-page">
|
<div class="catalog-page">
|
||||||
<CatalogHeader total={PREVIEW_TOTAL} />
|
<CatalogHeader total={PREVIEW_TOTAL} />
|
||||||
<CatalogToolbar bind:query bind:category />
|
<CatalogToolbar bind:query bind:category />
|
||||||
|
|||||||
Reference in New Issue
Block a user