mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add slider field
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import CheckboxField from './ui/CheckboxField.svelte';
|
import CheckboxField from './ui/CheckboxField.svelte';
|
||||||
import ColorField from './ui/ColorField.svelte';
|
import ColorField from './ui/ColorField.svelte';
|
||||||
import SelectField from './ui/SelectField.svelte';
|
import SelectField from './ui/SelectField.svelte';
|
||||||
|
import SliderField from './ui/SliderField.svelte';
|
||||||
import TextField from './ui/TextField.svelte';
|
import TextField from './ui/TextField.svelte';
|
||||||
import type { ParamDef } from '$lib/registry';
|
import type { ParamDef } from '$lib/registry';
|
||||||
|
|
||||||
@@ -26,6 +27,16 @@
|
|||||||
step={param.step}
|
step={param.step}
|
||||||
bind:value={values[param.id]}
|
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'}
|
{:else if param.type === 'select'}
|
||||||
<SelectField
|
<SelectField
|
||||||
id={param.id}
|
id={param.id}
|
||||||
|
|||||||
@@ -8,15 +8,52 @@
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
|
default?: number;
|
||||||
hint?: string;
|
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>
|
</script>
|
||||||
|
|
||||||
<Field {id} {label} {hint}>
|
<Field {id} {label} {hint}>
|
||||||
<div class="row">
|
<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 />
|
<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>
|
<output>{value}</output>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -25,16 +62,48 @@
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1);
|
||||||
max-width: var(--control-max-width);
|
max-width: var(--control-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
accent-color: var(--accent);
|
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 {
|
output {
|
||||||
|
flex: none;
|
||||||
min-width: 3ch;
|
min-width: 3ch;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { brightnessContrast, grayscale, invert } from './color';
|
import { brightnessContrast, grayscale, invert, rgbToHex } from './color';
|
||||||
import { makeImage } from './test-helpers';
|
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', () => {
|
describe('grayscale', () => {
|
||||||
it('считает luma по весам BT.601 с округлением', () => {
|
it('считает luma по весам BT.601 с округлением', () => {
|
||||||
const out = grayscale(
|
const out = grayscale(
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export function brightnessContrast(
|
|||||||
return out;
|
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 {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe('реестр инструментов', () => {
|
|||||||
expect(tool.description.length).toBeGreaterThan(0);
|
expect(tool.description.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('у select дефолт входит в options, у number дефолт в диапазоне', () => {
|
it('у select дефолт входит в options, у number/slider дефолт в диапазоне', () => {
|
||||||
for (const tool of TOOLS) {
|
for (const tool of TOOLS) {
|
||||||
for (const param of tool.params) {
|
for (const param of tool.params) {
|
||||||
if (param.type === 'select') {
|
if (param.type === 'select') {
|
||||||
@@ -61,6 +61,11 @@ describe('реестр инструментов', () => {
|
|||||||
expect(param.min === undefined || param.default >= param.min).toBe(true);
|
expect(param.min === undefined || param.default >= param.min).toBe(true);
|
||||||
expect(param.max === undefined || param.default <= param.max).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') {
|
if (param.type === 'color') {
|
||||||
expect(param.default).toMatch(/^#[0-9a-f]{6}$/i);
|
expect(param.default).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
}
|
}
|
||||||
@@ -80,7 +85,8 @@ describe('реестр инструментов', () => {
|
|||||||
const output = outputOf(tool);
|
const output = outputOf(tool);
|
||||||
if (output?.qualityParamId) {
|
if (output?.qualityParamId) {
|
||||||
const param = tool.params.find(
|
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();
|
expect(param).toBeDefined();
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-6
@@ -15,6 +15,15 @@ export type ParamDef =
|
|||||||
step?: number;
|
step?: number;
|
||||||
default: number;
|
default: number;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'slider';
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
default: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -179,8 +188,8 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.',
|
description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.',
|
||||||
category: 'color',
|
category: 'color',
|
||||||
params: [
|
params: [
|
||||||
{ id: 'brightness', 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: 'number', 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'))
|
run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast'))
|
||||||
},
|
},
|
||||||
@@ -192,7 +201,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
category: 'convert',
|
category: 'convert',
|
||||||
params: [
|
params: [
|
||||||
{ id: 'background', label: 'Цвет подложки', type: 'color', default: '#ffffff' },
|
{ 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' },
|
output: { mime: 'image/jpeg', ext: 'jpg', qualityParamId: 'quality' },
|
||||||
run: (img, p) => flattenOntoColor(img, str(p, 'background'))
|
run: (img, p) => flattenOntoColor(img, str(p, 'background'))
|
||||||
@@ -202,7 +211,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
title: 'Конвертировать PNG в WebP',
|
title: 'Конвертировать PNG в WebP',
|
||||||
description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.',
|
description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.',
|
||||||
category: 'convert',
|
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' },
|
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
|
||||||
run: (img) => clonePixelImage(img)
|
run: (img) => clonePixelImage(img)
|
||||||
},
|
},
|
||||||
@@ -214,7 +223,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
category: 'alpha',
|
category: 'alpha',
|
||||||
params: [
|
params: [
|
||||||
{ id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' },
|
{ 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')),
|
run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')),
|
||||||
preview: (img, p) => colorMask(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) {
|
for (const param of tool.params) {
|
||||||
const raw = values[param.id];
|
const raw = values[param.id];
|
||||||
switch (param.type) {
|
switch (param.type) {
|
||||||
case 'number': {
|
case 'number':
|
||||||
|
case 'slider': {
|
||||||
const n =
|
const n =
|
||||||
typeof raw === 'number' && Number.isFinite(raw) ? raw : param.default;
|
typeof raw === 'number' && Number.isFinite(raw) ? raw : param.default;
|
||||||
out[param.id] = clampRange(n, param.min, param.max);
|
out[param.id] = clampRange(n, param.min, param.max);
|
||||||
|
|||||||
Reference in New Issue
Block a user