mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
fix: update user input flow (keep aspect ratio)
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
## 1. Реактивные параметры без кнопки «Применить»
|
||||||
|
|
||||||
|
Процесс надо выстроить автоматически: кнопка «Применить» не нужна — все изменения параметров должны применяться к результату реактивно, с дебаунсом (~200–400 мс после последнего изменения), чтобы не пересчитывать тяжёлые операции на каждое движение ползунка.
|
||||||
|
|
||||||
|
- Убрать кнопку, статус «processing» показывать ненавязчиво (лёгкая индикация поверх превью).
|
||||||
|
- Дебаунс + отмена устаревших запусков (актуален только последний набор параметров).
|
||||||
|
- Распространить тот же механизм на будущий пайплайн-workspace.
|
||||||
|
|
||||||
|
## 2. Более user-friendly интерфейс
|
||||||
|
|
||||||
|
Сделать управление параметрами удобнее:
|
||||||
|
|
||||||
|
- **Range slider вместо полей ввода** там, где это удобнее (яркость/контраст, качество JPEG/WebP, порог похожести) — желательно slider с присоединённым числовым значением.
|
||||||
|
- **Пипетка для цвета** — выбор цвета удаления/подложки кликом прямо по превью изображения (в дополнение к `<input type="color">`).
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user