// Новая типизированная схема параметров (для нового UI, старый UI не трогаем).
//
// Параллельный источник правды к старому `ParamDef[]`: старый pipeline
// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/
// валидацию поверх `ToolSchema
`.
//
// Связка типов на этапе компиляции (защита от рассинхрона):
// - `field.x()` возвращает `Field` — phantom `__fieldT` связывает
// runtime-спецификацию поля с ожидаемым типом значения (`number`|`string`|`boolean`).
// - `toolSchema(fields)` требует, чтобы ключи `fields` в точности совпадали
// с полями `P`, а `Field
` тип-проверялся на соответствие `P[K]`.
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
import type { Position9 } from "./core/textdraw";
import type { TextFont } from "./core/domText";
export interface NumberSpec {
kind: "number";
default: number;
min?: number;
max?: number;
step?: number;
}
export interface SliderSpec {
kind: "slider";
default: number;
min: number;
max: number;
step?: number;
}
export interface ColorSpec {
kind: "color";
default: string;
}
export interface SelectSpec {
kind: "select";
default: V;
options: { value: V; label: string }[];
}
export interface TextSpec {
kind: "text";
default: string;
placeholder?: string;
}
export interface CheckboxSpec {
kind: "checkbox";
default: boolean;
}
/** Составное поле «размеры»: width + height как один объект. */
export interface Dimension {
width: number;
height: number;
}
export interface DimensionSpec {
kind: "dimension";
/** Общий диапазон для обоих измерений. */
min: number;
max: number;
width: number;
height: number;
}
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
export interface ColorPair {
from: string;
to: string;
}
export interface ColorPairSpec {
kind: "color-pair";
from: string;
to: string;
}
/** Список цветов (палитра): массив hex-строк. */
export type ColorList = string[];
export interface ColorListSpec {
kind: "colors";
default: string[];
}
/** Смещение фигуры/объекта по центру: x + y в процентах. */
export interface Offset {
x: number;
y: number;
}
export interface OffsetSpec {
kind: "offset";
/** Общий диапазон для обеих осей (в процентах). */
min: number;
max: number;
x: number;
y: number;
}
/**
* 9-позиционная сетка (3×3): top/middle/bottom × left/center/right.
* Значение совпадает со строковым типом `Position9` из core/textdraw.
*/
export const POSITION9_VALUES = [
"top-left",
"top-center",
"top-right",
"middle-left",
"center",
"middle-right",
"bottom-left",
"bottom-center",
"bottom-right",
] as const;
export interface Position9Spec {
kind: "position9";
default: Position9;
}
/**
* Стиль текстовой надписи: шрифт, размер, жирность и цвет как один объект
* (переиспользуется text-to-png, add-text, date-stamp).
*/
export interface FontStyle {
font: TextFont;
size: number;
bold: boolean;
color: string;
}
export interface FontStyleSpec {
kind: "font-style";
/** Диапазон размера шрифта. */
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}
/**
* Подложка-плашка под текстовой надписью: вкл/выкл, цвет и непрозрачность
* как один объект (add-text, date-stamp).
*/
export interface Plate {
enabled: boolean;
color: string;
opacity: number;
}
export interface PlateSpec {
kind: "plate";
enabled: boolean;
color: string;
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` = значение по любому
* ключу (тот же union). Добавляем новый составной тип — добавляем сюда, и
* type-checker укажет, где его не хватает (record контролов, default/sanitize).
*/
export const fieldSpecs = {
number: {} as NumberSpec,
slider: {} as SliderSpec,
color: {} as ColorSpec,
select: {} as SelectSpec,
text: {} as TextSpec,
checkbox: {} as CheckboxSpec,
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
colors: {} as ColorListSpec,
offset: {} as OffsetSpec,
position9: {} as Position9Spec,
"font-style": {} as FontStyleSpec,
plate: {} as PlateSpec,
gradient: {} as GradientSpec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
export type FieldSpecOf = (typeof fieldSpecs)[K];
export type FieldSpec = FieldSpecOf;
/** `Field`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */
export interface Field {
spec: FieldSpec;
/** Phantom, только для типобезопасности — не задаётся и не читается в рантайме. */
__fieldT?: T;
}
export interface ToolLayoutGroup {
/** Подпись группы (пустая группа не рендерится). */
title?: string;
/** Количество колонок сетки внутри группы (по умолчанию 1). */
cols?: number;
/** Имена полей схемы, попадающих в группу. */
fields: string[];
}
export interface ToolSchemaLayout {
/** Группы полей виджета. Неупомянутые поля — в общей группе в конце. */
groups: ToolLayoutGroup[];
}
export interface ToolSchemaMeta {
/** Пер-инструмент раскладка полей (группировка/колонки). */
layout?: ToolSchemaLayout;
/** Короткая подпись инструмента для нового UI (необязательно). */
label?: string;
}
export interface ToolSchema {
fields: { [K in keyof P]: Field
};
layout?: ToolSchemaLayout;
label?: string;
}
export const field = {
number: (s: Omit): Field => ({
spec: { kind: "number", ...s },
}),
slider: (s: Omit): Field => ({
spec: { kind: "slider", ...s },
}),
color: (s: Omit): Field => ({
spec: { kind: "color", ...s },
}),
select: (s: Omit, "kind">): Field => ({
spec: { kind: "select", ...s },
}),
text: (s: Omit): Field => ({
spec: { kind: "text", ...s },
}),
checkbox: (s: Omit): Field => ({
spec: { kind: "checkbox", ...s },
}),
dimension: (s: {
min: number;
max: number;
width: number;
height: number;
}): Field => ({
spec: { kind: "dimension", ...s },
}),
colorPair: (s: { from: string; to: string }): Field => ({
spec: { kind: "color-pair", ...s },
}),
colors: (s: { default: string[] }): Field => ({
spec: { kind: "colors", ...s },
}),
offset: (s: {
min: number;
max: number;
x: number;
y: number;
}): Field => ({
spec: { kind: "offset", ...s },
}),
position9: (s: { default: Position9 }): Field => ({
spec: { kind: "position9", ...s },
}),
fontStyle: (s: {
min: number;
max: number;
size: number;
font: TextFont;
bold: boolean;
color: string;
}): Field => ({
spec: { kind: "font-style", ...s },
}),
plate: (s: {
enabled: boolean;
color: string;
opacity: number;
}): Field => ({
spec: { kind: "plate", ...s },
}),
gradient: (s: {
from: string;
to: string;
angle: number;
}): Field => ({
spec: { kind: "gradient", ...s },
}),
};
/**
* Собирает `ToolSchema` из объявленных полей.
* Компилятор проверяет: ключи `fields` === полям `P`, типы полей совместимы с `P[K]`.
*/
export function toolSchema
(
fields: { [K in keyof P]: Field
},
meta?: ToolSchemaMeta,
): ToolSchema
{
return { fields, ...(meta ?? {}) };
}
function clamp(value: number, min?: number, max?: number): number {
if (min !== undefined && value < min) return min;
if (max !== undefined && value > max) return max;
return value;
}
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
export function defaultSchemaParams
(
schema: ToolSchema
,
): Record {
const out = {} as Record;
for (const key of Object.keys(schema.fields) as (keyof 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 if (spec.kind === "colors") {
out[key] = [...spec.default];
} else if (spec.kind === "offset") {
out[key] = { x: spec.x, y: spec.y };
} else if (spec.kind === "font-style") {
out[key] = {
font: spec.font,
size: spec.size,
bold: spec.bold,
color: spec.color,
};
} else if (spec.kind === "plate") {
out[key] = {
enabled: spec.enabled,
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;
}
}
return out;
}
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
export function sanitizeSchemaParams(
schema: ToolSchema
,
values: Record,
): Record {
const out = {} as Record;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
const raw = values[key as string];
switch (spec.kind) {
case "number":
case "slider": {
const n =
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
out[key] = clamp(n, spec.min, spec.max);
break;
}
case "select":
out[key] =
typeof raw === "string" && spec.options.some((o) => o.value === raw)
? raw
: spec.default;
break;
case "checkbox":
out[key] = typeof raw === "boolean" ? raw : spec.default;
break;
case "color":
out[key] =
typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw)
? raw
: spec.default;
break;
case "text":
out[key] = typeof raw === "string" ? raw : spec.default;
break;
case "dimension": {
const r =
typeof raw === "object" &&
raw !== null &&
"width" in raw &&
"height" in raw
? (raw as Record)
: undefined;
const w =
typeof r?.width === "number" && Number.isFinite(r.width)
? r.width
: spec.width;
const h =
typeof r?.height === "number" && Number.isFinite(r.height)
? r.height
: spec.height;
out[key] = {
width: clamp(w, spec.min, spec.max),
height: clamp(h, spec.min, spec.max),
};
break;
}
case "color-pair": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw
? (raw as Record)
: 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;
}
case "colors": {
const r =
Array.isArray(raw) &&
raw.every((c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c))
? (raw as string[])
: undefined;
out[key] = r && r.length > 0 ? [...r] : [...spec.default];
break;
}
case "position9":
out[key] =
typeof raw === "string" &&
(POSITION9_VALUES as readonly string[]).includes(raw)
? (raw as Position9)
: spec.default;
break;
case "font-style": {
const r =
typeof raw === "object" &&
raw !== null &&
"font" in raw &&
"size" in raw &&
"bold" in raw &&
"color" in raw
? (raw as Record)
: undefined;
out[key] = {
font:
r?.font === "sans" || r?.font === "serif" || r?.font === "mono"
? r.font
: spec.font,
size:
typeof r?.size === "number" && Number.isFinite(r.size)
? clamp(r.size, spec.min, spec.max)
: spec.size,
bold: typeof r?.bold === "boolean" ? r.bold : spec.bold,
color:
typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color)
? r.color
: spec.color,
};
break;
}
case "plate": {
const r =
typeof raw === "object" &&
raw !== null &&
"enabled" in raw &&
"color" in raw &&
"opacity" in raw
? (raw as Record)
: undefined;
out[key] = {
enabled: typeof r?.enabled === "boolean" ? r.enabled : spec.enabled,
color:
typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color)
? r.color
: spec.color,
opacity:
typeof r?.opacity === "number" && Number.isFinite(r.opacity)
? clamp(r.opacity, 0, 100)
: spec.opacity,
};
break;
}
case "offset": {
const r =
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
? (raw as Record)
: undefined;
const x =
typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x;
const y =
typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y;
out[key] = {
x: clamp(x, spec.min, spec.max),
y: clamp(y, spec.min, spec.max),
};
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;
}