diff --git a/docs/backlog.md b/docs/backlog.md index aa94d1c..8d0f568 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -6,10 +6,8 @@ ## UX — приоритетная очередь -Порядок согласован: C (контролы полей) → B (загрузка исходника) → E (download) → -D (pipeline). Делать по одной, сверху вниз. +Порядок согласован: E (download) → D (pipeline). Делать по одной, сверху вниз. -- [ ] **B. Загрузка исходника** — ctrl+v и drag-and-drop (dropzone) - [ ] **E. Download** — кнопка сразу пишет текущий формат, селектор PNG/JPG/WebP/BMP для всех инструментов, предупреждение при несовместимости формата (альфа-канал); описание формата из блока исходник/результат diff --git a/web/e2e/helpers/page.ts b/web/e2e/helpers/page.ts index dd78666..087e2ad 100644 --- a/web/e2e/helpers/page.ts +++ b/web/e2e/helpers/page.ts @@ -76,8 +76,10 @@ export async function renderText(page: Page, value: string): Promise { } export async function uploadImage(page: Page, file: SourceFile): Promise { - const input = page.locator('input[type="file"]'); - await expect(input).toHaveCount(1); + // На странице может быть два input[type=file]: дропзона + кнопка Open image; + // канонический — первый (кнопка в шапке панели). + const input = page.locator('input[type="file"]').first(); + await expect(input).toBeAttached(); await input.setInputFiles({ name: file.name, mimeType: file.mimeType, @@ -85,6 +87,35 @@ export async function uploadImage(page: Page, file: SourceFile): Promise { }); } +export async function uploadViaDrop( + page: Page, + file: SourceFile, +): Promise { + 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 { const button = page.getByRole("button", { name: "Download result" }); await expect(button).toBeVisible(); diff --git a/web/e2e/tool-flows.spec.ts b/web/e2e/tool-flows.spec.ts index cdfe6b2..7e8646e 100644 --- a/web/e2e/tool-flows.spec.ts +++ b/web/e2e/tool-flows.spec.ts @@ -18,6 +18,7 @@ import { sourceImage, trackErrors, uploadImage, + uploadViaDrop, useEnglish, } from "./helpers/page"; @@ -81,6 +82,15 @@ test("crop-png starts with the full source area", async ({ page }) => { 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 }) => { const sink = trackErrors(page); await openTool(page, "flip-png"); diff --git a/web/e2e/tsconfig.json b/web/e2e/tsconfig.json new file mode 100644 index 0000000..438aac4 --- /dev/null +++ b/web/e2e/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/web/eslint.config.js b/web/eslint.config.js index a9bae5f..e00bbab 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -59,16 +59,7 @@ export default tseslint.config( "vitest.config.ts", "eslint.config.js", "playwright.config.ts", - "e2e/helpers/fixtures.ts", - "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", + // e2e покрыты e2e/tsconfig.json. // Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в // tsconfig), поэтому для типизированного парсинга резолвятся // через default-проект. Перечисляются точечно: `**` в diff --git a/web/src/lib/components/schema/SchemaPreview.svelte b/web/src/lib/components/schema/SchemaPreview.svelte index ba81bfa..b1dd184 100644 --- a/web/src/lib/components/schema/SchemaPreview.svelte +++ b/web/src/lib/components/schema/SchemaPreview.svelte @@ -78,6 +78,7 @@ {running} ontextinput={ontextsource} {onrendertext} + {onupload} /> void; onrendertext?: () => void; + onupload?: (file: File) => void; } let { mode, @@ -21,9 +23,19 @@ running = false, ontextinput, onrendertext, + onupload, }: Props = $props(); 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); + } @@ -37,9 +49,29 @@ /> {:else if mode === "image" && sourceUrl} - {t("sourceCard.alt")} +
{ + e.preventDefault(); + dragging = true; + }} + ondragleave={() => (dragging = false)} + ondrop={onDrop} + > + {t("sourceCard.alt")} + {#if dragging} + {t("dropZone.overlayDefault")} + {/if} +
{:else if mode === "image"} - {t("sourceCard.chooseImage")} + {:else} {t("sourceCard.noSource")} {/if} @@ -54,4 +86,32 @@ padding: var(--space-l); 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; + } diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index b71cca4..bd0b17e 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -22,6 +22,7 @@ type ToolSchema, } from "$lib/registry-schema"; import { downloadZip } from "$lib/zip"; + import { onMount } from "svelte"; import { SvelteSet } from "svelte/reactivity"; interface Props { @@ -235,6 +236,25 @@ init(); }); + 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 (!started) return; if (inputMode === "image" && !source) return; diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 194f6bf..8a92db2 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -122,7 +122,7 @@ export const en: Dict = { pickDefault: "Drop an image here or click to choose a file", overlayDefault: "Release the file to replace the image", pickTitle: "Drop a PNG here", - pickHint: "or click to browse — processed locally", + pickHint: "or click / paste (Ctrl+V) — processed locally", }, search: { placeholder: "Find a tool…", diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index ddb4d82..b2e59d8 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -123,7 +123,7 @@ export const ru: Dict = { pickDefault: "Перетащите изображение сюда или нажмите, чтобы выбрать файл", overlayDefault: "Отпустите файл, чтобы заменить изображение", pickTitle: "Перетащите PNG сюда", - pickHint: "или нажмите, чтобы выбрать — всё локально", + pickHint: "или нажмите / вставьте (Ctrl+V) — всё локально", }, search: { placeholder: "Найдите инструмент…",