From cad9bf6cfd48aef22d27610d735a3e76bd4baaab Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 5 Sep 2026 17:19:03 +0500 Subject: [PATCH] feat: add gradient control component --- docs/plan-composite-params.md | 25 +++- .../lib/components/kit/SchemaFields.svelte | 2 + .../kit/fields/schema/AngleControl.svelte | 107 ++++++++++++++++++ .../kit/fields/schema/GradientControl.svelte | 96 ++++++++++++++++ web/src/lib/registry-new/generate.ts | 65 ++++++++--- web/src/lib/registry-new/registry-new.test.ts | 62 +++++++++- web/src/lib/registry-schema.ts | 57 ++++++++++ 7 files changed, 393 insertions(+), 21 deletions(-) create mode 100644 web/src/lib/components/kit/fields/schema/AngleControl.svelte create mode 100644 web/src/lib/components/kit/fields/schema/GradientControl.svelte diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index 514bcbc..f308de6 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -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` + `toolSchema

()` + `ToolSchema

` — цвет + слайдер непрозрачности, деактивируется при выключенной плашке), 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 на остальные инструменты diff --git a/web/src/lib/components/kit/SchemaFields.svelte b/web/src/lib/components/kit/SchemaFields.svelte index 5d6c34c..3c0051c 100644 --- a/web/src/lib/components/kit/SchemaFields.svelte +++ b/web/src/lib/components/kit/SchemaFields.svelte @@ -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 { diff --git a/web/src/lib/components/kit/fields/schema/AngleControl.svelte b/web/src/lib/components/kit/fields/schema/AngleControl.svelte new file mode 100644 index 0000000..23edf70 --- /dev/null +++ b/web/src/lib/components/kit/fields/schema/AngleControl.svelte @@ -0,0 +1,107 @@ + + +

+ {#if label} + {label} + {/if} +
+ onchange(Number((e.target as HTMLInputElement).value))} + /> + {value}° +
+
+ {#each presets as preset (preset.value)} + + {/each} +
+
+ + diff --git a/web/src/lib/components/kit/fields/schema/GradientControl.svelte b/web/src/lib/components/kit/fields/schema/GradientControl.svelte new file mode 100644 index 0000000..01c22b4 --- /dev/null +++ b/web/src/lib/components/kit/fields/schema/GradientControl.svelte @@ -0,0 +1,96 @@ + + +
+ {label} +
+ + +
+ set("angle", a)} /> +
+ + diff --git a/web/src/lib/registry-new/generate.ts b/web/src/lib/registry-new/generate.ts index bbe2b6b..a2de6d2 100644 --- a/web/src/lib/registry-new/generate.ts +++ b/web/src/lib/registry-new/generate.ts @@ -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 = { interface LinearGradientParams { size: Dimension; - pair: ColorPair; - direction: "horizontal" | "vertical"; + gradient: Gradient; } export const linearGradientSchema = toolSchema({ 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 = { 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, + ); }, }; diff --git a/web/src/lib/registry-new/registry-new.test.ts b/web/src/lib/registry-new/registry-new.test.ts index 3b6bacb..24d92d1 100644 --- a/web/src/lib/registry-new/registry-new.test.ts +++ b/web/src/lib/registry-new/registry-new.test.ts @@ -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, { diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index 81503f8..79ee188 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -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 => ({ spec: { kind: "plate", ...s }, }), + gradient: (s: { + from: string; + to: string; + angle: number; + }): Field => ({ + spec: { kind: "gradient", ...s }, + }), }; /** @@ -302,6 +328,12 @@ export function defaultSchemaParams

( 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

( }; break; } + case "gradient": { + const r = + typeof raw === "object" && + raw !== null && + "from" in raw && + "to" in raw && + "angle" in raw + ? (raw as Record) + : 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;