Compare commits

...
12 Commits
36 changed files with 1566 additions and 276 deletions
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["./**/*.ts"]
}
+1 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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"] {
+54 -30
View File
@@ -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"
{draggable}
ondragstart={(e) => ondragstart?.(e)}
ondragover={(e) => ondragover?.(e)}
ondrop={(e) => ondrop?.(e)}
ondragend={() => ondragend?.()}
>
<header class="step-head">
<span class="step-index">{index.toString().padStart(2, "0")}</span>
<GripVertical size={16} class="drag" aria-hidden="true" /> <GripVertical size={16} class="drag" aria-hidden="true" />
<div class="step-body">
<div class="step-heading">
<div class="step-heading-text"> <div class="step-heading-text">
{#if type}<span class="step-type">{type}</span>{/if} {#if type}<span class="step-type">{type}</span>{/if}
{#if title}<h2 class="step-title">{title}</h2>{/if} {#if title}<h2 class="step-title">{title}</h2>{/if}
</div> </div>
<div class="step-tools"> <div class="step-tools">
{#if tools}{@render tools()}{/if} {#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} />
</div>
</div>
{#if children}
{@render children()}
{/if} {/if}
</div> </div>
</header>
{#if children}
<div class="step-body">
{@render children()}
</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,31 +161,108 @@
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);
} }
} }
async function run() { function assignResult(out: ToolResult) {
if (!schema) return;
displayError = null;
running = true;
try {
const out = await execute(tool, {
params: sanitizeSchemaParams(schema, values, {
source: source ?? undefined,
}),
source: source ?? undefined,
text: textSource || undefined,
});
if (resultKind === "image") { if (resultKind === "image") {
result = out as PixelImage; result = out as PixelImage;
textResult = null; textResult = null;
@@ -128,6 +285,47 @@
result = null; result = null;
fileResult = null; fileResult = null;
} }
}
async function run() {
if (!schema || steps.length === 0) return;
displayError = null;
running = true;
try {
let current: PixelImage | undefined = source ?? undefined;
const dims: (Dimension | undefined)[] = [];
let out: ToolResult = "";
for (let i = 0; i < steps.length; i++) {
const stepTool = getTool(steps[i].id);
if (!stepTool) continue;
try {
out = await execute(stepTool, {
params: steps[i].params,
source: stepTool.input === "image" ? current : undefined,
text:
stepTool.input === "text" ? textSource || undefined : undefined,
});
} catch (e) {
const base = toDisplayError(e);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
throw new Error(
t("chain.stepError", {
n: i + 1,
title: stepTitle(stepTool.id),
msg,
}),
{ cause: e },
);
}
if (out && typeof out === "object" && "data" in out) {
current = out as PixelImage;
dims[i] = { width: current.width, height: current.height };
} else {
current = undefined;
}
}
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)}
<StepCard
index={i + 1}
title={stepTitle(step.id)}
draggable
onremove={steps.length > 1
? () => 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 <SchemaFields
{schema} schema={toolSchemaOf(step)}
{values} values={step.params}
toolId={tool.id} toolId={step.id}
onchange={setValue} onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
onreset={reset} 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)
+34 -3
View File
@@ -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>
+39
View File
@@ -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";
+6 -7
View File
@@ -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",
+6 -7
View File
@@ -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: "От",
-1
View File
@@ -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"],
]; ];
+142
View File
@@ -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;
}
+94
View File
@@ -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();
});
});
+35
View File
@@ -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,
+35 -1
View File
@@ -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];
} }
+8
View File
@@ -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",
+5
View File
@@ -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,
}), }),
}, },
{ {
+4
View File
@@ -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 />
+7
View File
@@ -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>
+5
View File
@@ -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 />