feat: add gradient control component

This commit is contained in:
2026-09-05 17:19:03 +05:00
parent b99ccf5d78
commit cad9bf6cfd
7 changed files with 393 additions and 21 deletions
+19 -6
View File
@@ -13,12 +13,14 @@
> add-text-png и date-stamp-png** (водяной знак-картинка — отдельный шаг:
> требует overlay-механику в новом превью); **составной тип `font-style`
> полностью переведён** (text-to-png, add-text, date-stamp); **составной тип
> `plate` полностью переведён** (add-text, date-stamp); preview
> `plate` полностью переведён** (add-text, date-stamp); **составной тип
> `gradient` полностью переведён** (linear-gradient): вместо горизонтального/
> вертикального select — **угол 0..360°** (новый виджет `AngleControl`:
> slider + кнопки-пресеты 0°/90°/180°/270°, переиспользуемый компонент);
> preview
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
> color-pair/offset/position9/font-style/plate).
> Следующее: `gradient` — собрать из dimension + color-pair + direction на
> linear-gradient.
> color-pair/offset/position9/font-style/plate/gradient).
>
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
@@ -390,8 +392,19 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
цвет + слайдер непрозрачности, деактивируется при выключенной плашке),
kind `plate` в схеме (default/sanitize, clamp opacity к 0..100).
Покрытие тестов расширено (дефолты/sanitize plate в существующих тестах).
30. `gradient` — собрать из dimension + color-pair + direction на
linear-gradient (зависит от решения по gradient, см. план)
30. `gradient` — **переведён** ✔ (linear-gradient-png). Направление градиента
— не горизонталь/вертикаль, а **произвольный угол**: новый переиспользуемый
виджет `kit/fields/schema/AngleControl.svelte` (слайдер 0..360° + кнопки
стандартных углов 0°/90°/180°/270° с активным состоянием). Составной тип
`gradient: { from, to, angle }` во всех видах (`field.gradient`, kind
`gradient` в схеме — default/sanitize, clamp угла к 0..360, валидация
цветов; виджет `kit/fields/schema/GradientControl.svelte` — пара цветов +
AngleControl). Рендер по углу — локальный `angleGradient` в
`registry-new/generate.ts` (core-`gradientImage` не трогали: он умеет
только horizontal/vertical и используется старым UI). Семантика: 0° слева
направо, 90° сверху вниз, рост угла по часовой (ось Y вниз); нормализация
по полному диапазону проекции на ось (180°/270° разворачивают градиент).
Тесты: дефолты, направление по углу (0°/90°/180°), sanitize/кламп.
### Фаза 4 — масштаб UI на остальные инструменты
@@ -17,6 +17,7 @@
import PositionControl from "./fields/schema/PositionControl.svelte";
import FontStyleControl from "./fields/schema/FontStyleControl.svelte";
import PlateControl from "./fields/schema/PlateControl.svelte";
import GradientControl from "./fields/schema/GradientControl.svelte";
interface FieldControlProps {
label: string;
@@ -38,6 +39,7 @@
position9: PositionControl,
"font-style": FontStyleControl,
plate: PlateControl,
gradient: GradientControl,
};
interface Props {
@@ -0,0 +1,107 @@
<script lang="ts">
interface Preset {
value: number;
label: string;
}
interface Props {
label?: string;
value: number;
min?: number;
max?: number;
step?: number;
presets?: Preset[];
onchange: (angle: number) => void;
}
let {
label = "Angle",
value,
min = 0,
max = 360,
step = 1,
presets = [
{ value: 0, label: "0°" },
{ value: 90, label: "90°" },
{ value: 180, label: "180°" },
{ value: 270, label: "270°" },
],
onchange,
}: Props = $props();
</script>
<div class="angle-control">
{#if label}
<span class="angle-label">{label}</span>
{/if}
<div class="angle-row">
<input
type="range"
{min}
{max}
{step}
{value}
oninput={(e) => onchange(Number((e.target as HTMLInputElement).value))}
/>
<output>{value}°</output>
</div>
<div class="angle-presets">
{#each presets as preset (preset.value)}
<button
class:active={preset.value === value}
onclick={() => onchange(preset.value)}>{preset.label}</button
>
{/each}
</div>
</div>
<style>
.angle-control {
display: grid;
gap: var(--space-m);
}
.angle-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.angle-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-m);
}
.angle-row input[type="range"] {
width: 100%;
accent-color: var(--color-main);
}
.angle-row output {
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.angle-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-m);
}
.angle-presets button {
padding: var(--space-m) 0;
color: var(--color-text-muted);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
cursor: pointer;
}
.angle-presets button:hover {
color: var(--color-text);
border-color: var(--color-main);
}
.angle-presets button.active {
color: var(--color-main);
border-color: var(--color-main);
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import AngleControl from "./AngleControl.svelte";
import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Gradient) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as GradientSpec);
const current = $derived.by(() => {
const v = value as Partial<Gradient> | undefined;
return {
from: typeof v?.from === "string" ? v.from : sp.from,
to: typeof v?.to === "string" ? v.to : sp.to,
angle:
typeof v?.angle === "number" && Number.isFinite(v.angle)
? v.angle
: sp.angle,
};
});
function set<K extends keyof Gradient>(key: K, val: Gradient[K]) {
onchange?.({ ...current, [key]: val });
}
</script>
<div class="control gradient-control">
<span class="gradient-label">{label}</span>
<div class="gradient-colors">
<label class="color-field">
<span>From</span>
<span class="swatch" style="background:{current.from}"></span>
<input
type="color"
value={current.from}
oninput={(e) => set("from", (e.target as HTMLInputElement).value)}
/>
</label>
<label class="color-field">
<span>To</span>
<span class="swatch" style="background:{current.to}"></span>
<input
type="color"
value={current.to}
oninput={(e) => set("to", (e.target as HTMLInputElement).value)}
/>
</label>
</div>
<AngleControl value={current.angle} onchange={(a) => set("angle", a)} />
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.gradient-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.gradient-colors {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m) var(--space-l);
}
.color-field {
display: grid;
grid-template-columns: auto var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.color-field input[type="color"] {
width: 100%;
height: var(--space-xxl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
}
</style>
+52 -13
View File
@@ -1,7 +1,8 @@
import { renderTextToImage } from "../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { gradientImage, noiseImage, solidImage } from "../core/generate";
import { noiseImage, solidImage } from "../core/generate";
import { changeCanvasSize } from "../core/geometry";
import type { PixelImage } from "../core/types";
import {
hexToRgb,
renderBlend,
@@ -14,6 +15,7 @@ import {
type ColorPair,
type Dimension,
type FontStyle,
type Gradient,
} from "../registry-schema";
import type { ToolEntry } from "./types";
@@ -22,6 +24,45 @@ function rgba(hex: string): [number, number, number, number] {
return [r, g, b, 255];
}
/**
* Линейный градиент по произвольному углу. 0° — слева направо, 90° — сверху
* вниз (рост угла по часовой, ось Y вниз). Угол задаёт направление оси
* градиента; t пикселя — нормализованная проекция на эту ось.
*/
function angleGradient(
width: number,
height: number,
fromRgba: [number, number, number, number],
toRgba: [number, number, number, number],
angle: number,
): PixelImage {
const rad = (angle * Math.PI) / 180;
const vx = Math.cos(rad);
const vy = Math.sin(rad);
// Минимум и максимум проекции на ось достигаются в противоположных углах.
const rightX = vx >= 0 ? width - 1 : 0;
const bottomY = vy >= 0 ? height - 1 : 0;
const projMax = rightX * vx + bottomY * vy;
const projMin = (width - 1 - rightX) * vx + (height - 1 - bottomY) * vy;
const span = projMax - projMin;
const out: PixelImage = {
width,
height,
data: new Uint8ClampedArray(width * height * 4),
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const t = span === 0 ? 0 : (x * vx + y * vy - projMin) / span;
const i = (y * width + x) * 4;
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
out.data[i + 2] = fromRgba[2] + (toRgba[2] - fromRgba[2]) * t;
out.data[i + 3] = fromRgba[3] + (toRgba[3] - fromRgba[3]) * t;
}
}
return out;
}
interface CreateEmptyParams {
size: Dimension;
transparent: boolean;
@@ -100,33 +141,31 @@ const randomNoise: ToolEntry<RandomNoiseParams> = {
interface LinearGradientParams {
size: Dimension;
pair: ColorPair;
direction: "horizontal" | "vertical";
gradient: Gradient;
}
export const linearGradientSchema = toolSchema<LinearGradientParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
direction: field.select({
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
}),
gradient: field.gradient({ from: "#000000", to: "#ffffff", angle: 0 }),
});
const linearGradient: ToolEntry<LinearGradientParams> = {
id: "linear-gradient-png",
title: "Create gradient PNG",
description:
"Generates a smooth transition between two colors, horizontally or vertically.",
"Generates a smooth transition between two colors along a chosen angle.",
category: "generate",
schema: linearGradientSchema,
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return gradientImage(w, h, rgba(p.pair.from), rgba(p.pair.to), p.direction);
return angleGradient(
w,
h,
rgba(p.gradient.from),
rgba(p.gradient.to),
p.gradient.angle,
);
},
};
+60 -2
View File
@@ -65,8 +65,11 @@ describe("registry-new (переведённые инструменты)", () =>
"linear-gradient-png",
{
size: { width: 40, height: 30 },
pair: { from: "#000000", to: "#ffffff" },
direction: "horizontal",
gradient: {
from: "#000000",
to: "#ffffff",
angle: 90,
},
},
],
[
@@ -235,6 +238,61 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.data[102]).toBe(255);
});
it("linear-gradient: дефолты gradient и направление по углу", async () => {
const tool = TOOLS.find((t) => t.id === "linear-gradient-png")!;
const d = defaultSchemaParams(tool.schema);
expect(d.gradient).toEqual({
from: "#000000",
to: "#ffffff",
angle: 0,
});
// 0° — слева направо: крайний левый пиксель = from, крайний правый = to
let img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 4, height: 1 },
gradient: { from: "#000000", to: "#ffffff", angle: 0 },
}),
);
expect(img.data[0]).toBe(0);
expect(img.data[12]).toBe(255);
// 90° — сверху вниз: верхний пиксель = from, нижний = to
img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 1, height: 4 },
gradient: { from: "#000000", to: "#ffffff", angle: 90 },
}),
);
expect(img.data[0]).toBe(0);
expect(img.data[12]).toBe(255);
// 180° — разворот: левый пиксель = to
img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
size: { width: 4, height: 1 },
gradient: { from: "#000000", to: "#ffffff", angle: 180 },
}),
);
expect(img.data[0]).toBe(255);
expect(img.data[12]).toBe(0);
});
it("sanitize чинит мусор в gradient и клампит angle в 0..360", () => {
const tool = TOOLS.find((t) => t.id === "linear-gradient-png")!;
const s = sanitizeSchemaParams(tool.schema, {
size: { width: 10, height: 10 },
gradient: {
from: "not-a-color",
to: "#00ff00",
angle: 720,
extra: 1,
},
});
expect(s.gradient).toEqual({
from: "#000000",
to: "#00ff00",
angle: 360,
});
});
it("sanitize чинит мусор в паре и клампит threshold", () => {
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
const s = sanitizeSchemaParams(tool.schema, {
+57
View File
@@ -154,6 +154,24 @@ export interface PlateSpec {
opacity: number;
}
/**
* Цветовой градиент: пара цветов + угол направления. 0° — слева направо,
* 90° — сверху вниз (прирост по часовой в пиксельных осях, ось Y вниз).
*/
export interface Gradient {
from: string;
to: string;
angle: number;
}
export interface GradientSpec {
kind: "gradient";
from: string;
to: string;
/** Направление градиента: 0..360°, угол в градусах. */
angle: number;
}
/**
* «Объект as const» kind → спека поля. Единственный источник правды для
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
@@ -173,6 +191,7 @@ export const fieldSpecs = {
position9: {} as Position9Spec,
"font-style": {} as FontStyleSpec,
plate: {} as PlateSpec,
gradient: {} as GradientSpec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
@@ -257,6 +276,13 @@ export const field = {
}): Field<Plate> => ({
spec: { kind: "plate", ...s },
}),
gradient: (s: {
from: string;
to: string;
angle: number;
}): Field<Gradient> => ({
spec: { kind: "gradient", ...s },
}),
};
/**
@@ -302,6 +328,12 @@ export function defaultSchemaParams<P>(
color: spec.color,
opacity: spec.opacity,
};
} else if (spec.kind === "gradient") {
out[key] = {
from: spec.from,
to: spec.to,
angle: spec.angle,
};
} else {
out[key] = spec.default;
}
@@ -456,6 +488,31 @@ export function sanitizeSchemaParams<P>(
};
break;
}
case "gradient": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw &&
"angle" in raw
? (raw as Record<string, unknown>)
: undefined;
out[key] = {
from:
typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from)
? r.from
: spec.from,
to:
typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to)
? r.to
: spec.to,
angle:
typeof r?.angle === "number" && Number.isFinite(r.angle)
? clamp(r.angle, 0, 360)
: spec.angle,
};
break;
}
}
}
return out;