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;