feat: add gradient control component
This commit is contained in:
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user