feat: add slider field
This commit is contained in:
@@ -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'}
|
||||
<SliderField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
default={param.default}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === 'select'}
|
||||
<SelectField
|
||||
id={param.id}
|
||||
|
||||
@@ -8,15 +8,52 @@
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
default?: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let { id, label, value = $bindable(0), min, max, step, hint }: Props = $props();
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
value = $bindable(0),
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
default: defaultValue,
|
||||
hint
|
||||
}: Props = $props();
|
||||
|
||||
function decrement() {
|
||||
if (min !== undefined && value <= min) return;
|
||||
value = Math.max(min ?? -Infinity, value - (step ?? 1));
|
||||
}
|
||||
|
||||
function increment() {
|
||||
if (max !== undefined && value >= max) return;
|
||||
value = Math.min(max ?? Infinity, value + (step ?? 1));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (defaultValue !== undefined) value = defaultValue;
|
||||
}
|
||||
|
||||
const resetDisabled = $derived(defaultValue === undefined || value === defaultValue);
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<div class="row">
|
||||
<button type="button" class="step" aria-label="Уменьшить" onclick={decrement}>−</button>
|
||||
<input id={id} type="range" min={min} max={max} step={step} bind:value />
|
||||
<button type="button" class="step" aria-label="Увеличить" onclick={increment}>+</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Сбросить"
|
||||
disabled={resetDisabled}
|
||||
onclick={reset}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
<output>{value}</output>
|
||||
</div>
|
||||
</Field>
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<ParamDef, { type: 'number' }> => p.id === output.qualityParamId
|
||||
(p): p is Extract<ParamDef, { type: 'number' | 'slider' }> =>
|
||||
p.id === output.qualityParamId
|
||||
);
|
||||
expect(param).toBeDefined();
|
||||
}
|
||||
|
||||
+16
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user