diff --git a/web/src/lib/components/ParamForm.svelte b/web/src/lib/components/ParamForm.svelte index 65b2491..9f8027a 100644 --- a/web/src/lib/components/ParamForm.svelte +++ b/web/src/lib/components/ParamForm.svelte @@ -2,6 +2,7 @@ import CheckboxField from './ui/CheckboxField.svelte'; import ColorField from './ui/ColorField.svelte'; import SelectField from './ui/SelectField.svelte'; + import SliderField from './ui/SliderField.svelte'; import TextField from './ui/TextField.svelte'; import type { ParamDef } from '$lib/registry'; @@ -26,6 +27,16 @@ step={param.step} bind:value={values[param.id]} /> + {:else if param.type === 'slider'} + {:else if param.type === 'select'} = max) return; + value = Math.min(max ?? Infinity, value + (step ?? 1)); + } + + function reset() { + if (defaultValue !== undefined) value = defaultValue; + } + + const resetDisabled = $derived(defaultValue === undefined || value === defaultValue);
+ + + {value}
@@ -25,16 +62,48 @@ .row { display: flex; align-items: center; - gap: var(--space-2); + gap: var(--space-1); max-width: var(--control-max-width); } input[type='range'] { flex: 1; + min-width: 0; accent-color: var(--accent); } + .step { + flex: none; + width: 1.7rem; + height: 1.7rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-s); + background: var(--surface); + color: var(--text); + font-size: 0.9rem; + line-height: 1; + cursor: pointer; + transition: + border-color var(--transition-fast), + color var(--transition-fast); + } + + .step:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + } + + .step:disabled { + opacity: 0.45; + cursor: not-allowed; + } + output { + flex: none; min-width: 3ch; text-align: right; font-family: var(--font-mono); diff --git a/web/src/lib/core/color.test.ts b/web/src/lib/core/color.test.ts index 7714499..5a56913 100644 --- a/web/src/lib/core/color.test.ts +++ b/web/src/lib/core/color.test.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { brightnessContrast, grayscale, invert } from './color'; +import { brightnessContrast, grayscale, invert, rgbToHex } from './color'; import { makeImage } from './test-helpers'; +describe('rgbToHex', () => { + it('форматирует базовые цвета', () => { + expect(rgbToHex(255, 0, 0)).toBe('#ff0000'); + expect(rgbToHex(1, 2, 3)).toBe('#010203'); + }); + + it('округляет дробные значения и клампит диапазон', () => { + expect(rgbToHex(127.6, -5, 300)).toBe('#8000ff'); + }); +}); + describe('grayscale', () => { it('считает luma по весам BT.601 с округлением', () => { const out = grayscale( diff --git a/web/src/lib/core/color.ts b/web/src/lib/core/color.ts index 188185f..d69cf33 100644 --- a/web/src/lib/core/color.ts +++ b/web/src/lib/core/color.ts @@ -42,6 +42,11 @@ export function brightnessContrast( return out; } +export function rgbToHex(r: number, g: number, b: number): string { + const byte = (v: number) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0'); + return `#${byte(r)}${byte(g)}${byte(b)}`; +} + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts index 7eea275..9dc6aa3 100644 --- a/web/src/lib/registry.test.ts +++ b/web/src/lib/registry.test.ts @@ -50,7 +50,7 @@ describe('реестр инструментов', () => { expect(tool.description.length).toBeGreaterThan(0); }); - it('у select дефолт входит в options, у number дефолт в диапазоне', () => { + it('у select дефолт входит в options, у number/slider дефолт в диапазоне', () => { for (const tool of TOOLS) { for (const param of tool.params) { if (param.type === 'select') { @@ -61,6 +61,11 @@ describe('реестр инструментов', () => { expect(param.min === undefined || param.default >= param.min).toBe(true); expect(param.max === undefined || param.default <= param.max).toBe(true); } + if (param.type === 'slider') { + expect(param.min).toBeLessThan(param.max); + expect(param.default).toBeGreaterThanOrEqual(param.min); + expect(param.default).toBeLessThanOrEqual(param.max); + } if (param.type === 'color') { expect(param.default).toMatch(/^#[0-9a-f]{6}$/i); } @@ -80,7 +85,8 @@ describe('реестр инструментов', () => { const output = outputOf(tool); if (output?.qualityParamId) { const param = tool.params.find( - (p): p is Extract => p.id === output.qualityParamId + (p): p is Extract => + p.id === output.qualityParamId ); expect(param).toBeDefined(); } diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 76648ac..ba86b57 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -15,6 +15,15 @@ export type ParamDef = step?: number; default: number; } + | { + id: string; + label: string; + type: 'slider'; + min: number; + max: number; + step?: number; + default: number; + } | { id: string; label: string; @@ -179,8 +188,8 @@ export const TOOLS: ToolEntry[] = [ description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.', category: 'color', params: [ - { id: 'brightness', label: 'Яркость', type: 'number', min: -100, max: 100, step: 1, default: 0 }, - { id: 'contrast', label: 'Контраст', type: 'number', min: -100, max: 100, step: 1, default: 0 } + { id: 'brightness', label: 'Яркость', type: 'slider', min: -100, max: 100, step: 1, default: 0 }, + { id: 'contrast', label: 'Контраст', type: 'slider', min: -100, max: 100, step: 1, default: 0 } ], run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast')) }, @@ -192,7 +201,7 @@ export const TOOLS: ToolEntry[] = [ category: 'convert', params: [ { id: 'background', label: 'Цвет подложки', type: 'color', default: '#ffffff' }, - { id: 'quality', label: 'Качество JPEG', type: 'number', min: 1, max: 100, step: 1, default: 90 } + { id: 'quality', label: 'Качество JPEG', type: 'slider', min: 1, max: 100, step: 1, default: 90 } ], output: { mime: 'image/jpeg', ext: 'jpg', qualityParamId: 'quality' }, run: (img, p) => flattenOntoColor(img, str(p, 'background')) @@ -202,7 +211,7 @@ export const TOOLS: ToolEntry[] = [ title: 'Конвертировать PNG в WebP', description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.', category: 'convert', - params: [{ id: 'quality', label: 'Качество WebP', type: 'number', min: 1, max: 100, step: 1, default: 90 }], + params: [{ id: 'quality', label: 'Качество WebP', type: 'slider', min: 1, max: 100, step: 1, default: 90 }], output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' }, run: (img) => clonePixelImage(img) }, @@ -214,7 +223,7 @@ export const TOOLS: ToolEntry[] = [ category: 'alpha', params: [ { id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' }, - { id: 'tolerance', label: 'Порог похожести, %', type: 'number', min: 0, max: 100, step: 1, default: 10 } + { id: 'tolerance', label: 'Порог похожести, %', type: 'slider', min: 0, max: 100, step: 1, default: 10 } ], run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')), preview: (img, p) => colorMask(img, str(p, 'targetColor'), num(p, 'tolerance')) @@ -247,7 +256,8 @@ export function sanitizeParams( for (const param of tool.params) { const raw = values[param.id]; switch (param.type) { - case 'number': { + case 'number': + case 'slider': { const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : param.default; out[param.id] = clampRange(n, param.min, param.max);