diff --git a/docs/backlog.md b/docs/backlog.md index 8d0f568..f1db7a5 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -6,12 +6,8 @@ ## UX — приоритетная очередь -Порядок согласован: E (download) → D (pipeline). Делать по одной, сверху вниз. +Порядок согласован: D (pipeline). Делать по одной, сверху вниз. -- [ ] **E. Download** — кнопка сразу пишет текущий формат, селектор - PNG/JPG/WebP/BMP для всех инструментов, предупреждение при несовместимости - формата (альфа-канал); описание формата из блока исходник/результат - переезжает в кнопку. Райз «Download» → S1f (`docs/plan-seo.md` §4) - [ ] **D. Pipeline** — процесс редактирования и настройки: drag-n-drop, удаление, добавление инструментов; переход «один инструмент ↔ pipeline» без перезагрузки. Следом — идеи 1–3 (смена первого инструмента, @@ -38,6 +34,10 @@ - [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из `helpers/page.ts` вместо вызова — правило «спека зовёт helpers» (`docs/testing-strategy.md`, раздел 5) +- [ ] e2e webkit: случайные падения по таймауту 5с (элемент не появляется), + каждый прогон падают разные тесты — webkit под Windows слишком медленный. + Рассмотреть увеличение timeout/expectTimeout для webkit-проекта в + `playwright.config.ts` - [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его ничем не читают. Либо показывать подсказку для инструментов с пустой схемой (FA-E1), либо удалить ключ из обеих локалей diff --git a/web/e2e/helpers/page.ts b/web/e2e/helpers/page.ts index 087e2ad..384b70b 100644 --- a/web/e2e/helpers/page.ts +++ b/web/e2e/helpers/page.ts @@ -117,7 +117,8 @@ export async function uploadViaDrop( } export async function downloadResultFile(page: Page): Promise { - 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(); const [download] = await Promise.all([ page.waitForEvent("download"), @@ -126,6 +127,14 @@ export async function downloadResultFile(page: Page): Promise { return download; } +export async function chooseDownloadFormat( + page: Page, + format: string, +): Promise { + await page.getByRole("button", { name: "Choose download format" }).click(); + await page.getByRole("menuitemradio", { name: format, exact: true }).click(); +} + export async function downloadResultBytes( page: Page, ): Promise<{ name: string; bytes: Uint8Array }> { diff --git a/web/e2e/tool-flows.spec.ts b/web/e2e/tool-flows.spec.ts index 7e8646e..be62b58 100644 --- a/web/e2e/tool-flows.spec.ts +++ b/web/e2e/tool-flows.spec.ts @@ -6,8 +6,10 @@ import { largePng, onePixelPng, opaquePng, + transparentPng, } from "./helpers/fixtures"; import { + chooseDownloadFormat, downloadResultBytes, errorAlert, expectNoErrorAlert, @@ -144,6 +146,26 @@ test("convert-png-to-webp: produces WebP and quality affects size", async ({ 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 }) => { const sink = trackErrors(page); await openTool(page, "png-to-bmp"); diff --git a/web/src/app.css b/web/src/app.css index f4b2498..de8a8c2 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -176,6 +176,8 @@ --duration-s: 0.15s; --duration-l: 0.8s; + + --z-dropdown: 10; } [data-theme="dark"] { diff --git a/web/src/lib/components/schema/SchemaActions.svelte b/web/src/lib/components/schema/SchemaActions.svelte index bd34e7d..e071803 100644 --- a/web/src/lib/components/schema/SchemaActions.svelte +++ b/web/src/lib/components/schema/SchemaActions.svelte @@ -1,31 +1,66 @@
{#if inputMode === "image"} {/if} - {#if canDownload} + {#if canDownload && resultKind === "image"} + onformat?.(v)} + onquality={(v) => onquality?.(v)} + /> + {:else if canDownload && resultKind === "files"} {/if}
+{#if canDownload && alphaLoss} +

+ {t("actions.alphaLoss", { format: outputFormatByMime(format).label })} +

+{/if} diff --git a/web/src/lib/components/schema/SchemaDownload.svelte b/web/src/lib/components/schema/SchemaDownload.svelte new file mode 100644 index 0000000..70ebccc --- /dev/null +++ b/web/src/lib/components/schema/SchemaDownload.svelte @@ -0,0 +1,186 @@ + + +
+ + + {#if open} + + {/if} +
+ + diff --git a/web/src/lib/components/schema/SchemaPreview.svelte b/web/src/lib/components/schema/SchemaPreview.svelte index b1dd184..47a82ec 100644 --- a/web/src/lib/components/schema/SchemaPreview.svelte +++ b/web/src/lib/components/schema/SchemaPreview.svelte @@ -5,6 +5,7 @@ import SchemaResultTile from "./SchemaResultTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte"; import type { PixelImage } from "$lib/core/types"; + import type { OutputMime } from "$lib/core/io"; import { t } from "$lib/i18n/t"; import type { FileResult, InputMode, ResultKind } from "$lib/registry"; @@ -20,12 +21,17 @@ textVars?: Record; running?: boolean; error?: string; + format?: OutputMime; + quality?: number; + alphaLoss?: boolean; onupload: (file: File) => void; ontextsource?: (text: string) => void; onrendertext?: () => void; oncopytext?: () => void; ondownloadtxt?: () => void; ondownload: () => void; + onformat?: (mime: OutputMime) => void; + onquality?: (value: number) => void; } let { inputMode, @@ -39,12 +45,17 @@ textVars = undefined, running = false, error = "", + format = "image/png", + quality = undefined, + alphaLoss = false, onupload, ontextsource, onrendertext, oncopytext, ondownloadtxt, ondownload, + onformat, + onquality, }: Props = $props(); const model = $derived( @@ -59,10 +70,16 @@ {t("resultCard.previewPanel")} @@ -98,7 +115,6 @@ items={[ { caption: t("sourceCard.source"), value: model.sourceValue }, { caption: t("resultCard.result"), value: model.resultValue }, - { caption: t("resultCard.format"), value: model.formatValue }, ]} /> diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index bd0b17e..4669141 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -2,8 +2,15 @@ import SchemaFields from "./SchemaFields.svelte"; import SchemaPreview from "./SchemaPreview.svelte"; import { debounce } from "$lib/core/debounce"; + import { hasTransparency } from "$lib/core/analyze"; 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 { execute } from "$lib/executor"; import { @@ -58,6 +65,33 @@ let running = $state(false); let displayError = $state(null); let started = $state(false); + let format = $state("image/png"); + // Качество lossy-форматов для инструментов без quality-параметра в схеме. + let formatQuality = $state>({}); + + const alphaLoss = $derived( + resultKind === "image" && + result !== null && + !outputFormatByMime(format).supportsAlpha && + hasTransparency(result), + ); + // Если в схеме есть quality-параметр (convert-инструменты), он — единый + // источник качества; dropdown редактирует его же. + const qualityParamId = $derived(tool.output?.qualityParamId); + const currentQuality = $derived.by(() => { + const setting = outputFormatByMime(format).settings?.quality; + if (!setting) return undefined; + if (qualityParamId) return Number(values[qualityParamId]); + return formatQuality[format] ?? setting.default; + }); + + function setFormatQuality(q: number) { + if (qualityParamId) { + setValue(qualityParamId, q); + } else { + formatQuality = { ...formatQuality, [format]: q }; + } + } const errorText = $derived.by(() => { const current = displayError; @@ -73,6 +107,7 @@ if (!schema || started) return; started = true; values = defaultSchemaParams(schema); + format = tool.output?.mime ?? "image/png"; } function setValue( @@ -196,12 +231,11 @@ return; } if (!result) return; - const out = tool.output; - const quality = out?.qualityParamId - ? Number(values[out.qualityParamId]) / 100 - : undefined; - const blob = await encode(result, out?.mime ?? "image/png", quality); - downloadBlob(blob, `${page.slug}.${out?.ext ?? "png"}`); + const out = outputFormatByMime(format); + const quality = + currentQuality !== undefined ? currentQuality / 100 : undefined; + const blob = await encode(result, out.mime, quality); + downloadBlob(blob, `${page.slug}.${out.ext}`); } catch (e) { displayError = toDisplayError(e); } finally { @@ -311,6 +345,11 @@ {resultKind} {running} error={errorText} + {format} + quality={currentQuality} + {alphaLoss} + onformat={(v) => (format = v)} + onquality={setFormatQuality} onupload={handleFile} ontextsource={(textValue) => { textSource = textValue; diff --git a/web/src/lib/components/schema/schema-preview-model.test.ts b/web/src/lib/components/schema/schema-preview-model.test.ts index d3813b1..36ce5e5 100644 --- a/web/src/lib/components/schema/schema-preview-model.test.ts +++ b/web/src/lib/components/schema/schema-preview-model.test.ts @@ -56,7 +56,6 @@ describe("buildSchemaPreviewModel", () => { ).toEqual({ sourceValue: "—", resultValue: "2 × 3 px", - formatValue: "PNG", hasResult: true, }); expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({ @@ -75,7 +74,6 @@ describe("buildSchemaPreviewModel", () => { ).toEqual({ sourceValue: "—", resultValue: "1 parts", - formatValue: "ZIP (PNG)", hasResult: true, }); expect( @@ -96,7 +94,6 @@ describe("buildSchemaPreviewModel", () => { ), ).toMatchObject({ resultValue: "Text", - formatValue: "PNG", hasResult: true, }); expect( diff --git a/web/src/lib/components/schema/schema-preview-model.ts b/web/src/lib/components/schema/schema-preview-model.ts index 862f603..42a987e 100644 --- a/web/src/lib/components/schema/schema-preview-model.ts +++ b/web/src/lib/components/schema/schema-preview-model.ts @@ -15,7 +15,6 @@ export type SchemaPreviewModelInput = { export type SchemaPreviewModel = { sourceValue: string; resultValue: string; - formatValue: string; hasResult: boolean; }; @@ -49,7 +48,6 @@ export function buildSchemaPreviewModel( return { sourceValue, resultValue, - formatValue: resultKind === "files" ? "ZIP (PNG)" : "PNG", hasResult: resultKind === "image" ? Boolean(result) diff --git a/web/src/lib/core/io.ts b/web/src/lib/core/io.ts index f8208d7..2f02e30 100644 --- a/web/src/lib/core/io.ts +++ b/web/src/lib/core/io.ts @@ -5,6 +5,45 @@ import { type PixelImage } from "./types"; export type OutputMime = "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 = "image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon"; diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 8a92db2..3331324 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -75,7 +75,6 @@ export const en: Dict = { result: "Result", previewPanel: "Preview", parts: "parts", - format: "Format", alt: "Result image", }, paramsCard: { @@ -100,7 +99,10 @@ export const en: Dict = { generate: "Generate", generating: "Generating…", openImage: "Open image", - downloadResult: "Download result", + download: "Download", + downloadFormat: "Download {format}", + formatAria: "Choose download format", + alphaLoss: "Result has transparency — {format} will flatten it", }, textSource: { placeholder: "Paste base64 / hex / bytes…", diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index b2e59d8..c258a56 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -76,7 +76,6 @@ export const ru: Dict = { result: "Результат", previewPanel: "Предпросмотр", parts: "частей", - format: "Формат", alt: "Изображение-результат", }, paramsCard: { @@ -101,7 +100,10 @@ export const ru: Dict = { generate: "Сгенерировать", generating: "Генерация…", openImage: "Открыть изображение", - downloadResult: "Скачать результат", + download: "Скачать", + downloadFormat: "Скачать {format}", + formatAria: "Выбор формата скачивания", + alphaLoss: "В результате есть прозрачность — {format} её уберёт", }, textSource: { placeholder: "Вставьте base64 / hex / байты…",