fix: update user input flow (keep aspect ratio)

This commit is contained in:
2026-08-22 11:33:10 +05:00
parent 4b7086693c
commit da55328491
4 changed files with 137 additions and 8 deletions
+16
View File
@@ -0,0 +1,16 @@
# Backlog
## 1. Реактивные параметры без кнопки «Применить»
Процесс надо выстроить автоматически: кнопка «Применить» не нужна — все изменения параметров должны применяться к результату реактивно, с дебаунсом (~200–400 мс после последнего изменения), чтобы не пересчитывать тяжёлые операции на каждое движение ползунка.
- Убрать кнопку, статус «processing» показывать ненавязчиво (лёгкая индикация поверх превью).
- Дебаунс + отмена устаревших запусков (актуален только последний набор параметров).
- Распространить тот же механизм на будущий пайплайн-workspace.
## 2. Более user-friendly интерфейс
Сделать управление параметрами удобнее:
- **Range slider вместо полей ввода** там, где это удобнее (яркость/контраст, качество JPEG/WebP, порог похожести) — желательно slider с присоединённым числовым значением.
- **Пипетка для цвета** — выбор цвета удаления/подложки кликом прямо по превью изображения (в дополнение к `<input type="color">`).
+4 -3
View File
@@ -2,7 +2,7 @@
import { imageInfo, type ImageInfo } from '$lib/core/analyze'; import { imageInfo, type ImageInfo } from '$lib/core/analyze';
import { decodeFile } from '$lib/core/io'; import { decodeFile } from '$lib/core/io';
import type { PixelImage } from '$lib/core/types'; 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 DownloadButton from './DownloadButton.svelte';
import DropZone from './DropZone.svelte'; import DropZone from './DropZone.svelte';
import InfoPanel from './InfoPanel.svelte'; import InfoPanel from './InfoPanel.svelte';
@@ -21,6 +21,7 @@
let values = $state<Record<string, any>>({}); let values = $state<Record<string, any>>({});
const isInfo = $derived(tool.resultType === 'info'); const isInfo = $derived(tool.resultType === 'info');
const sanitized = $derived(sanitizeParams(tool, values));
async function handleFile(file: File) { async function handleFile(file: File) {
errorText = ''; errorText = '';
@@ -43,7 +44,7 @@
async function runTool() { async function runTool() {
if (!source || isInfo) return; if (!source || isInfo) return;
try { try {
result = await tool.run(source, values); result = await tool.run(source, sanitized);
status = 'loaded'; status = 'loaded';
} catch (e) { } catch (e) {
showError(e); showError(e);
@@ -113,7 +114,7 @@
image={result} image={result}
format={outputOf(tool)} format={outputOf(tool)}
baseName={tool.id} baseName={tool.id}
params={values} params={sanitized}
onError={showError} onError={showError}
/> />
</div> </div>
+74 -1
View File
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CATEGORIES } from './categories'; 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 = [ const PLAN_TOOL_IDS = [
'resize-png', '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<string, unknown>) =>
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 { function getToolOrThrow(id: string): ToolEntry {
const tool = getTool(id); const tool = getTool(id);
if (!tool) throw new Error(`Инструмент "${id}" не найден`); if (!tool) throw new Error(`Инструмент "${id}" не найден`);
+43 -4
View File
@@ -65,7 +65,7 @@ export const TOOLS: ToolEntry[] = [
id: 'resize-png', id: 'resize-png',
title: 'Изменить размер PNG', title: 'Изменить размер PNG',
description: description:
'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций укажите только ширину или только высоту — вторая сторона рассчитается автоматически.', 'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций одна сторона задаёт масштаб, а если указаны обе — изображение вписывается в эти размеры.',
category: 'geometry', category: 'geometry',
params: [ params: [
{ id: 'width', label: 'Ширина (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 }, { 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')); let h = Math.trunc(num(p, 'height'));
if (keepAspect) { if (keepAspect) {
if (w > 0 && h > 0) { if (w > 0 && h > 0) {
throw new Error('При сохранении пропорций укажите только ширину или только высоту'); const scale = Math.min(w / img.width, h / img.height);
} w = Math.max(1, Math.round(img.width * scale));
if (w > 0) { h = Math.max(1, Math.round(img.height * scale));
} else if (w > 0) {
h = Math.max(1, Math.round((img.height / img.width) * w)); h = Math.max(1, Math.round((img.height / img.width) * w));
} else if (h > 0) { } else if (h > 0) {
w = Math.max(1, Math.round((img.width / img.height) * h)); w = Math.max(1, Math.round((img.width / img.height) * h));
@@ -233,6 +234,44 @@ export function defaultParams(tool: ToolEntry): Record<string, unknown> {
return Object.fromEntries(tool.params.map((p) => [p.id, p.default])); return Object.fromEntries(tool.params.map((p) => [p.id, p.default]));
} }
export function sanitizeParams(
tool: ToolEntry,
values: Record<string, unknown>
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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 { export function outputOf(tool: ToolEntry): OutputFormat | undefined {
if (tool.resultType === 'info') return undefined; if (tool.resultType === 'info') return undefined;
return tool.output ?? PNG_OUTPUT; return tool.output ?? PNG_OUTPUT;