feat: add two colors field control

This commit is contained in:
2026-09-05 16:09:39 +05:00
parent 4a13bfbd16
commit 19f2e13b88
7 changed files with 307 additions and 17 deletions
@@ -3,6 +3,7 @@
import type { ToolSchema } from "$lib/registry-schema";
import CheckboxControl from "./fields/schema/CheckboxControl.svelte";
import ColorControl from "./fields/schema/ColorControl.svelte";
import ColorPairControl from "./fields/schema/ColorPairControl.svelte";
import RangeControl from "./fields/schema/RangeControl.svelte";
import SelectControl from "./fields/schema/SelectControl.svelte";
import TextControl from "./fields/schema/TextControl.svelte";
@@ -20,6 +21,7 @@
number: RangeControl,
slider: RangeControl,
color: ColorControl,
"color-pair": ColorPairControl,
select: SelectControl,
checkbox: CheckboxControl,
text: TextControl,
@@ -0,0 +1,97 @@
<script lang="ts">
import type {
ColorPair,
ColorPairSpec,
FieldSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: ColorPair) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorPairSpec);
const current = $derived.by(() => {
const v = value as Partial<ColorPair> | undefined;
return {
from: typeof v?.from === "string" ? v.from : sp.from,
to: typeof v?.to === "string" ? v.to : sp.to,
};
});
function setColor(axis: "from" | "to", hex: string) {
onchange?.({ ...current, [axis]: hex });
}
</script>
<div class="control color-pair-control">
<span class="pair-label">{label}</span>
<div class="pair-field">
<label class="pair-axis">
<span class="swatch" style="background:{current.from}"></span>
<input
type="color"
value={current.from}
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)}
/>
<span class="axis-label">From</span>
</label>
<label class="pair-axis">
<span class="swatch" style="background:{current.to}"></span>
<input
type="color"
value={current.to}
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
/>
<span class="axis-label">To</span>
</label>
</div>
</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);
}
.pair-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.pair-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.pair-axis {
display: grid;
grid-template-columns: 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);
}
.pair-axis input[type="color"] {
width: 100%;
height: var(--space-xxl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-background);
cursor: pointer;
}
.axis-label {
grid-column: 1 / -1;
}
</style>
+23 -2
View File
@@ -1,9 +1,29 @@
import { gammaCorrection, temperature, tint } from "../core/color";
import { gammaCorrection, temperature, twoColors, tint } from "../core/color";
import { parseHexList } from "../core/palette";
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
import { field, toolSchema } from "../registry-schema";
import { field, toolSchema, type ColorPair } from "../registry-schema";
import type { ToolEntry } from "./types";
interface TwoColorsParams {
pair: ColorPair;
threshold: number;
}
export const twoColorsSchema = toolSchema<TwoColorsParams>({
pair: field.colorPair({ from: "#ffffff", to: "#000000" }),
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
const twoColorsTool: ToolEntry<TwoColorsParams> = {
id: "two-colors-png",
title: "Two colors PNG",
description:
"Recolors the image into two chosen colors by luminance threshold.",
category: "color",
schema: twoColorsSchema,
run: (img, p) => twoColors(img, p.pair.from, p.pair.to, p.threshold),
};
interface GammaParams {
value: number;
}
@@ -123,6 +143,7 @@ const ditheringTool: ToolEntry<DitheringParams> = {
};
export const colorEntries = [
twoColorsTool,
gammaTool,
temperatureTool,
tintTool,
+70 -7
View File
@@ -2,8 +2,18 @@ import { renderTextToImage } from "../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { gradientImage, noiseImage, solidImage } from "../core/generate";
import { changeCanvasSize } from "../core/geometry";
import { hexToRgb } from "../core/palette";
import { field, toolSchema, type Dimension } from "../registry-schema";
import {
hexToRgb,
renderBlend,
renderSwatches,
stepColors,
} from "../core/palette";
import {
field,
toolSchema,
type ColorPair,
type Dimension,
} from "../registry-schema";
import type { ToolEntry } from "./types";
function rgba(hex: string): [number, number, number, number] {
@@ -89,15 +99,13 @@ const randomNoise: ToolEntry<RandomNoiseParams> = {
interface LinearGradientParams {
size: Dimension;
fromColor: string;
toColor: string;
pair: ColorPair;
direction: "horizontal" | "vertical";
}
export const linearGradientSchema = toolSchema<LinearGradientParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
fromColor: field.color({ default: "#000000" }),
toColor: field.color({ default: "#ffffff" }),
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
direction: field.select({
default: "horizontal",
options: [
@@ -117,7 +125,7 @@ const linearGradient: ToolEntry<LinearGradientParams> = {
generate: (p) => {
const w = Math.trunc(p.size.width);
const h = Math.trunc(p.size.height);
return gradientImage(w, h, rgba(p.fromColor), rgba(p.toColor), p.direction);
return gradientImage(w, h, rgba(p.pair.from), rgba(p.pair.to), p.direction);
},
};
@@ -263,6 +271,59 @@ const placeholder: ToolEntry<PlaceholderParams> = {
},
};
interface BlendTwoParams {
pair: ColorPair;
width: number;
}
export const blendTwoSchema = toolSchema<BlendTwoParams>({
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
});
const blendTwo: ToolEntry<BlendTwoParams> = {
id: "blend-two-png",
title: "Blend Two Colors PNG",
description: "A continuous horizontal gradient between two colors.",
category: "generate",
schema: blendTwoSchema,
generate: (p) => renderBlend(p.pair.from, p.pair.to, p.width),
};
interface StepColorsParams {
pair: ColorPair;
steps: number;
width: number;
layout: "strip" | "grid";
}
export const stepColorsSchema = toolSchema<StepColorsParams>({
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }),
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
layout: field.select({
default: "grid",
options: [
{ value: "grid", label: "Grid" },
{ value: "strip", label: "Strip" },
],
}),
});
const stepColorsTool: ToolEntry<StepColorsParams> = {
id: "step-colors-png",
title: "Color Steps PNG",
description: "A discrete set of evenly spaced steps between two colors.",
category: "generate",
schema: stepColorsSchema,
generate: (p) =>
renderSwatches(
stepColors(p.pair.from, p.pair.to, p.steps),
p.width,
p.layout,
),
};
export const generateEntries = [
createEmpty,
singleColor,
@@ -272,4 +333,6 @@ export const generateEntries = [
randomColors,
drawGridTool,
placeholder,
blendTwo,
stepColorsTool,
];
+64 -2
View File
@@ -65,8 +65,7 @@ describe("registry-new (переведённые инструменты)", () =>
"linear-gradient-png",
{
size: { width: 40, height: 30 },
fromColor: "#000000",
toColor: "#ffffff",
pair: { from: "#000000", to: "#ffffff" },
direction: "horizontal",
},
],
@@ -182,6 +181,69 @@ describe("registry-new (переведённые инструменты)", () =>
expect(topLeft).toBe(255);
expect(center).toBe(0);
});
it("blend-two: дефолты пары и работа генератора", async () => {
const tool = TOOLS.find((t) => t.id === "blend-two-png")!;
const d = defaultSchemaParams(tool.schema);
expect(d.pair).toEqual({ from: "#000000", to: "#ffffff" });
const img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
pair: { from: "#ff0000", to: "#0000ff" },
width: 128,
}),
);
expect(img.width).toBe(128);
});
it("step-colors: рендерит steps полос по паре", async () => {
const tool = TOOLS.find((t) => t.id === "step-colors-png")!;
const img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
pair: { from: "#000000", to: "#ffffff" },
steps: 4,
width: 128,
layout: "strip",
}),
);
expect(img.width).toBe(128);
});
it("two-colors: перекрашивает светлые/тёмные пиксели по паре", async () => {
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
// 50×1, левая половина тёмная, правая светлая
const data = new Uint8ClampedArray(50 * 4).fill(255);
const img = { width: 50, height: 1, data };
for (let x = 0; x < 25; x++) {
const i = x * 4;
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
}
const out = await tool.run!(
img,
sanitizeSchemaParams(tool.schema, {
pair: { from: "#ffffff", to: "#ff0000" },
threshold: 50,
}),
);
// Светлый пиксель → from (#ffffff), тёмный → to (#ff0000)
expect(out.data[0]).toBe(255);
expect(out.data[1]).toBe(0);
expect(out.data[2]).toBe(0);
expect(out.data[100]).toBe(255);
expect(out.data[101]).toBe(255);
expect(out.data[102]).toBe(255);
});
it("sanitize чинит мусор в паре и клампит threshold", () => {
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
const s = sanitizeSchemaParams(tool.schema, {
pair: { from: "not-a-color", to: "#00ff00", extra: 1 },
threshold: 999,
});
expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" });
expect(s.threshold).toBe(100);
});
});
function solid(width: number, height: number) {
+38 -1
View File
@@ -64,6 +64,18 @@ export interface DimensionSpec {
height: number;
}
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
export interface ColorPair {
from: string;
to: string;
}
export interface ColorPairSpec {
kind: "color-pair";
from: string;
to: string;
}
export type FieldSpec =
| NumberSpec
| SliderSpec
@@ -71,7 +83,8 @@ export type FieldSpec =
| SelectSpec
| TextSpec
| CheckboxSpec
| DimensionSpec;
| DimensionSpec
| ColorPairSpec;
/** `Field<T>`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */
export interface Field<T> {
@@ -120,6 +133,9 @@ export const field = {
}): Field<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
spec: { kind: "color-pair", ...s },
}),
};
/**
@@ -148,6 +164,8 @@ export function defaultSchemaParams<P>(
const spec = schema.fields[key].spec;
if (spec.kind === "dimension") {
out[key] = { width: spec.width, height: spec.height };
} else if (spec.kind === "color-pair") {
out[key] = { from: spec.from, to: spec.to };
} else {
out[key] = spec.default;
}
@@ -212,6 +230,25 @@ export function sanitizeSchemaParams<P>(
};
break;
}
case "color-pair": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw
? (raw as Record<string, unknown>)
: undefined;
const from =
typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from)
? r.from
: spec.from;
const to =
typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to)
? r.to
: spec.to;
out[key] = { from, to };
break;
}
}
}
return out;