From da5532849198d3040a56f71a69fe6af6ae5b988f Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 22 Aug 2026 11:33:10 +0500 Subject: [PATCH] fix: update user input flow (keep aspect ratio) --- docs/backlog.md | 16 ++++++ web/src/lib/components/ToolPage.svelte | 7 +-- web/src/lib/registry.test.ts | 75 +++++++++++++++++++++++++- web/src/lib/registry.ts | 47 ++++++++++++++-- 4 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 docs/backlog.md diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..4ceef1c --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,16 @@ +# Backlog + +## 1. Реактивные параметры без кнопки «Применить» + +Процесс надо выстроить автоматически: кнопка «Применить» не нужна — все изменения параметров должны применяться к результату реактивно, с дебаунсом (~200–400 мс после последнего изменения), чтобы не пересчитывать тяжёлые операции на каждое движение ползунка. + +- Убрать кнопку, статус «processing» показывать ненавязчиво (лёгкая индикация поверх превью). +- Дебаунс + отмена устаревших запусков (актуален только последний набор параметров). +- Распространить тот же механизм на будущий пайплайн-workspace. + +## 2. Более user-friendly интерфейс + +Сделать управление параметрами удобнее: + +- **Range slider вместо полей ввода** там, где это удобнее (яркость/контраст, качество JPEG/WebP, порог похожести) — желательно slider с присоединённым числовым значением. +- **Пипетка для цвета** — выбор цвета удаления/подложки кликом прямо по превью изображения (в дополнение к ``). diff --git a/web/src/lib/components/ToolPage.svelte b/web/src/lib/components/ToolPage.svelte index 3fed503..ad9e250 100644 --- a/web/src/lib/components/ToolPage.svelte +++ b/web/src/lib/components/ToolPage.svelte @@ -2,7 +2,7 @@ import { imageInfo, type ImageInfo } from '$lib/core/analyze'; import { decodeFile } from '$lib/core/io'; import type { PixelImage } from '$lib/core/types'; - import { defaultParams, outputOf, type ToolEntry } from '$lib/registry'; + import { defaultParams, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry'; import DownloadButton from './DownloadButton.svelte'; import DropZone from './DropZone.svelte'; import InfoPanel from './InfoPanel.svelte'; @@ -21,6 +21,7 @@ let values = $state>({}); const isInfo = $derived(tool.resultType === 'info'); + const sanitized = $derived(sanitizeParams(tool, values)); async function handleFile(file: File) { errorText = ''; @@ -43,7 +44,7 @@ async function runTool() { if (!source || isInfo) return; try { - result = await tool.run(source, values); + result = await tool.run(source, sanitized); status = 'loaded'; } catch (e) { showError(e); @@ -113,7 +114,7 @@ image={result} format={outputOf(tool)} baseName={tool.id} - params={values} + params={sanitized} onError={showError} /> diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts index a238256..9d59707 100644 --- a/web/src/lib/registry.test.ts +++ b/web/src/lib/registry.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; import { CATEGORIES } from './categories'; -import { defaultParams, getTool, outputOf, TOOLS, type ParamDef, type ToolEntry } from './registry'; +import { + defaultParams, + getTool, + outputOf, + sanitizeParams, + TOOLS, + type ParamDef, + type ToolEntry +} from './registry'; const PLAN_TOOL_IDS = [ 'resize-png', @@ -89,6 +97,71 @@ describe('реестр инструментов', () => { }); }); +describe('sanitizeParams', () => { + it('заменяет пустые и невалидные числа на дефолт', () => { + const resize = getToolOrThrow('resize-png'); + expect(sanitizeParams(resize, { width: undefined, height: null, keepAspect: true })).toEqual({ + width: 0, + height: 0, + keepAspect: true + }); + }); + + it('клампит числа в диапазон параметра', () => { + const bc = getToolOrThrow('adjust-brightness-contrast-png'); + expect(sanitizeParams(bc, { brightness: 5000, contrast: -999 })).toEqual({ + brightness: 100, + contrast: -100 + }); + }); + + it('возвращает дефолт для невалидных select, checkbox и color', () => { + const rotate = getToolOrThrow('rotate-png'); + expect(sanitizeParams(rotate, { angle: '45' })).toEqual({ angle: '90' }); + const resize = getToolOrThrow('resize-png'); + expect(sanitizeParams(resize, { width: 10, height: 10, keepAspect: 'yes' })).toEqual({ + width: 10, + height: 10, + keepAspect: true + }); + const jpg = getToolOrThrow('convert-png-to-jpg'); + expect(sanitizeParams(jpg, { background: 'red', quality: 50 })).toEqual({ + background: '#ffffff', + quality: 50 + }); + }); +}); + +describe('run инструмента resize-png', () => { + const img = { width: 100, height: 50, data: new Uint8ClampedArray(100 * 50 * 4) }; + const runResize = async (params: Record) => + getToolOrThrow('resize-png').run(img, params); + + it('keepAspect + одна сторона — вторая считается по пропорции', async () => { + const out = await runResize({ width: 200, height: 0, keepAspect: true }); + expect(out.width).toBe(200); + expect(out.height).toBe(100); + }); + + it('keepAspect + обе стороны — вписывание в размеры без искажения', async () => { + const out = await runResize({ width: 50, height: 50, keepAspect: true }); + expect(out.width).toBe(50); + expect(out.height).toBe(25); + }); + + it('без keepAspect — обе стороны как задано', async () => { + const out = await runResize({ width: 30, height: 40, keepAspect: false }); + expect(out.width).toBe(30); + expect(out.height).toBe(40); + }); + + it('обе стороны 0 — человекочитаемая ошибка', async () => { + await expect(runResize({ width: 0, height: 0, keepAspect: true })).rejects.toThrow( + 'Укажите ширину' + ); + }); +}); + function getToolOrThrow(id: string): ToolEntry { const tool = getTool(id); if (!tool) throw new Error(`Инструмент "${id}" не найден`); diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 939658f..9a0e555 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -65,7 +65,7 @@ export const TOOLS: ToolEntry[] = [ id: 'resize-png', title: 'Изменить размер PNG', description: - 'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций укажите только ширину или только высоту — вторая сторона рассчитается автоматически.', + 'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций одна сторона задаёт масштаб, а если указаны обе — изображение вписывается в эти размеры.', category: 'geometry', params: [ { id: 'width', label: 'Ширина (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 }, @@ -78,9 +78,10 @@ export const TOOLS: ToolEntry[] = [ let h = Math.trunc(num(p, 'height')); if (keepAspect) { if (w > 0 && h > 0) { - throw new Error('При сохранении пропорций укажите только ширину или только высоту'); - } - if (w > 0) { + const scale = Math.min(w / img.width, h / img.height); + w = Math.max(1, Math.round(img.width * scale)); + h = Math.max(1, Math.round(img.height * scale)); + } else if (w > 0) { h = Math.max(1, Math.round((img.height / img.width) * w)); } else if (h > 0) { w = Math.max(1, Math.round((img.width / img.height) * h)); @@ -233,6 +234,44 @@ export function defaultParams(tool: ToolEntry): Record { return Object.fromEntries(tool.params.map((p) => [p.id, p.default])); } +export function sanitizeParams( + tool: ToolEntry, + values: Record +): Record { + const out: Record = {}; + for (const param of tool.params) { + const raw = values[param.id]; + switch (param.type) { + case 'number': { + const n = + typeof raw === 'number' && Number.isFinite(raw) ? raw : param.default; + out[param.id] = clampRange(n, param.min, param.max); + break; + } + case 'select': + out[param.id] = + typeof raw === 'string' && param.options.some((o) => o.value === raw) + ? raw + : param.default; + break; + case 'checkbox': + out[param.id] = typeof raw === 'boolean' ? raw : param.default; + break; + case 'color': + out[param.id] = + typeof raw === 'string' && /^#[0-9a-f]{6}$/i.test(raw) ? raw : param.default; + break; + } + } + return out; +} + +function clampRange(value: number, min?: number, max?: number): number { + if (min !== undefined && value < min) return min; + if (max !== undefined && value > max) return max; + return value; +} + export function outputOf(tool: ToolEntry): OutputFormat | undefined { if (tool.resultType === 'info') return undefined; return tool.output ?? PNG_OUTPUT;