feat: add two colors field control
This commit is contained in:
@@ -5,10 +5,14 @@
|
||||
> все одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension`
|
||||
> полностью переведён** (12 инструментов: create-empty, single-color,
|
||||
> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid,
|
||||
> placeholder, fit-on-background, change-canvas-size, resize, crop); preview
|
||||
> placeholder, fit-on-background, change-canvas-size, resize, crop); **составной
|
||||
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
||||
> step-colors, linear-gradient, two-colors); preview
|
||||
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
||||
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension).
|
||||
> Следующее: `color-pair` (пилот blend-two-png → two-colors → step-colors).
|
||||
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
||||
> color-pair).
|
||||
> Следующее: `offset` (пилот circle-mask-png → square-mask → star-mask →
|
||||
> wavy-mask).
|
||||
>
|
||||
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||
@@ -345,8 +349,12 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
||||
Preview: генераторы применяются через `executeGenerate` (кнопка Generate),
|
||||
`SchemaFields` рендерит все kinds схемы (slider/number/color/select/
|
||||
checkbox/dimension). Тесты: 592 passed.
|
||||
25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors →
|
||||
linear-gradient (уже переведён в dimension, здесь добавляется к нему)
|
||||
25. `color-pair` — **все 4 инструмента переведены** ✔ (`blend-two-png`,
|
||||
`step-colors-png`, `linear-gradient-png` — генераторы в
|
||||
`registry-new/generate.ts`, `two-colors-png` — run в `registry-new/color.ts`).
|
||||
Составной тип во всех видах: вложенный объект `pair: { from, to }` +
|
||||
`field.colorPair`, виджет `kit/fields/schema/ColorPairControl.svelte`,
|
||||
kind `color-pair` в схеме (default/sanitize). Тесты: +4 (596 passed).
|
||||
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask →
|
||||
wavy-mask
|
||||
27. `position9` — перевести add-text → date-stamp → watermark-image
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user