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);
+
+
+
@@ -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);