mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
refactor: separate registry schema file
This commit is contained in:
@@ -42,13 +42,15 @@ handleFiles, эффекты, workspace-разметка). Декомпозици
|
||||
`running` (`finally` сбрасывает только актуальный); `init()` инкрементом
|
||||
инвалидирует прогоны прошлого хоста.
|
||||
|
||||
### H3. `registry-schema.ts` — 723 строки, 45 экспортов, 5 ответственностей
|
||||
### H3. `registry-schema.ts` — 5 ответственностей — СДЕЛАНО (2026-10)
|
||||
|
||||
`web/src/lib/registry-schema.ts` смешивает: (а) типы 15 видов полей (:14-233),
|
||||
(б) builder-DSL `field.*` (:273-360), (в) layout engine `resolveLayoutGroups`
|
||||
(:380-406), (г) семантику значений `defaultSchemaParams`/`sanitizeSchemaParams`
|
||||
(:503-770), (д) aspect-lock геометрию `withAspectLock`/`clampSourceAwareMaxes`
|
||||
(:434-489). Каждая из (в)-(д) — отдельный модуль.
|
||||
Файл (769 строк) разрезан на директорию `web/src/lib/registry-schema/`:
|
||||
`specs.ts` (типы полей, `fieldSpecs`, `ToolSchema`), `field.ts` (builder DSL +
|
||||
`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`,
|
||||
`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (дефолты и санитизация);
|
||||
`index.ts` — баррель, пути импортов не менялись. `docs/architecture.md` (разделы
|
||||
1, 4) обновлён. JSDoc в `specs.ts` сжат до однострочников — лимит плотности
|
||||
комментариев (`conventions/comment-format`, 15%).
|
||||
|
||||
## Med
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ web/src/lib/
|
||||
registry/ # источник истины: инструменты и страницы
|
||||
registry/tools/ # Tool: реализации (id), без адресов и текстов
|
||||
registry/pages/ # Page: адрес, тексты, категория, список шагов
|
||||
registry-schema.ts # декларативная схема параметров + санитизация дефолтов
|
||||
registry-schema/ # декларативная схема параметров: specs (типы полей),
|
||||
# field (builder DSL), layout (группы), aspect (aspect-lock
|
||||
# и кэпы от source), values (дефолты и санитизация);
|
||||
# index.ts — баррель, импорты идут по `registry-schema`
|
||||
catalog.ts # группировка страниц и счётчики для /list-tools
|
||||
categories.ts # id/названия категорий
|
||||
tool-icons.ts # slug страницы → иконка
|
||||
@@ -170,7 +173,11 @@ registry/{tools,pages}/*.ts
|
||||
|
||||
## 4. Схема параметров
|
||||
|
||||
`registry-schema.ts` — единственное место, где описаны виды полей.
|
||||
`registry-schema/` — единственное место, где описаны виды полей. Модули:
|
||||
`specs.ts` (типы полей и `ToolSchema`), `field.ts` (фабрика `field.*` и
|
||||
`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`,
|
||||
`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (`defaultSchemaParams`,
|
||||
`applySourceDefaults`, `sanitizeSchemaParams`).
|
||||
|
||||
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
|
||||
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
|
||||
|
||||
@@ -1,769 +0,0 @@
|
||||
// Typed parameter schema for the UI.
|
||||
// Compile-time type binding (drift protection): `field.x<T>()` returns
|
||||
// `Field<T>` whose phantom `__fieldT` ties the runtime spec to the expected
|
||||
// value type; `toolSchema<P>(fields)` requires `fields` keys to match `P`
|
||||
// exactly. Compiler errors catch Params/schema mismatches.
|
||||
|
||||
import type { TextFont } from "./core/domText";
|
||||
import type { Position9 } from "./core/textdraw";
|
||||
import { clamp } from "./core/math";
|
||||
|
||||
/**
|
||||
* Optional field of every spec: the field's label key in the dictionary.
|
||||
* Rendered via `fieldLabel` (decision C); without a key, `labelOf(id)` is used.
|
||||
*/
|
||||
export interface FieldSpecBase {
|
||||
label?: string;
|
||||
/** Field visible only when another field equals a value (tool modes). */
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}
|
||||
|
||||
export interface NumberSpec extends FieldSpecBase {
|
||||
kind: "number";
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
/** Cap from source dimensions (axis); static max is the fallback. */
|
||||
maxFromSource?: "width" | "height";
|
||||
maxMinus?: number;
|
||||
}
|
||||
|
||||
export interface SliderSpec extends FieldSpecBase {
|
||||
kind: "slider";
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface ColorSpec extends FieldSpecBase {
|
||||
kind: "color";
|
||||
default: string;
|
||||
}
|
||||
|
||||
export interface SelectSpec<V extends string = string> extends FieldSpecBase {
|
||||
kind: "select";
|
||||
default: V;
|
||||
options: { value: V; label: string }[];
|
||||
}
|
||||
|
||||
/** Same as select but rendered as segment buttons (1-click choice). */
|
||||
export interface SegmentedSpec<
|
||||
V extends string = string,
|
||||
> extends FieldSpecBase {
|
||||
kind: "segmented";
|
||||
default: V;
|
||||
options: { value: V; label: string }[];
|
||||
}
|
||||
|
||||
export interface TextSpec extends FieldSpecBase {
|
||||
kind: "text";
|
||||
default: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface CheckboxSpec extends FieldSpecBase {
|
||||
kind: "checkbox";
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
/** Composite "dimensions" field: width + height as one object. */
|
||||
export interface Dimension {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Single list of tile presets (squares) for dimension fields. */
|
||||
export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const;
|
||||
|
||||
export interface DimensionSpec extends FieldSpecBase {
|
||||
kind: "dimension";
|
||||
/** Shared range for both dimensions. */
|
||||
min: number;
|
||||
max: number;
|
||||
width: number;
|
||||
height: number;
|
||||
defaultFromSource?: boolean;
|
||||
/** Per-axis cap from the matching source dimension. */
|
||||
maxFromSource?: boolean;
|
||||
/** Id of the "keep aspect ratio" checkbox field. */
|
||||
lockAspectWith?: string;
|
||||
/** Show quick-pick chips from SIZE_PRESETS. */
|
||||
presets?: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaContext {
|
||||
source?: Dimension;
|
||||
}
|
||||
|
||||
/** Color pair "from -> to" (gradients, two-color mapping, etc.). */
|
||||
export interface ColorPair {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ColorPairSpec extends FieldSpecBase {
|
||||
kind: "color-pair";
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Color list (palette): array of hex strings. */
|
||||
export type ColorList = string[];
|
||||
|
||||
export interface ColorListSpec extends FieldSpecBase {
|
||||
kind: "colors";
|
||||
default: string[];
|
||||
}
|
||||
|
||||
/** Shape/object offset from center: x + y in percent. */
|
||||
export interface Offset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface OffsetSpec extends FieldSpecBase {
|
||||
kind: "offset";
|
||||
/** Shared range for both axes (in percent). */
|
||||
min: number;
|
||||
max: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 9-position grid (3x3): top/middle/bottom x left/center/right.
|
||||
* Values match the `Position9` string type from 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 extends FieldSpecBase {
|
||||
kind: "position9";
|
||||
default: Position9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text caption style: font, size, bold, and color as one object
|
||||
* (reused by text-to-png, add-text, date-stamp).
|
||||
*/
|
||||
export interface FontStyle {
|
||||
font: TextFont;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface FontStyleSpec extends FieldSpecBase {
|
||||
kind: "font-style";
|
||||
/** Font size range. */
|
||||
min: number;
|
||||
max: number;
|
||||
size: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backing plate under a text caption: on/off, color, and opacity
|
||||
* as one object (add-text, date-stamp).
|
||||
*/
|
||||
export interface Plate {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface PlateSpec extends FieldSpecBase {
|
||||
kind: "plate";
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color gradient: color pair + direction angle. 0 deg: left to right,
|
||||
* 90 deg: top to bottom (clockwise growth in pixel axes, Y axis down).
|
||||
*/
|
||||
export interface Gradient {
|
||||
from: string;
|
||||
to: string;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export interface GradientSpec extends FieldSpecBase {
|
||||
kind: "gradient";
|
||||
from: string;
|
||||
to: string;
|
||||
/** Gradient direction: 0..360, angle in degrees. */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* "as const" object: kind -> field spec. Single source of truth for the kind
|
||||
* list: `FieldSpecKind` = map keys, `FieldSpec` = union of values. A new kind
|
||||
* added here makes the type-checker flag where it is missing (default/sanitize).
|
||||
*/
|
||||
export const fieldSpecs = {
|
||||
number: {} as NumberSpec,
|
||||
slider: {} as SliderSpec,
|
||||
color: {} as ColorSpec,
|
||||
select: {} as SelectSpec,
|
||||
segmented: {} as SegmentedSpec,
|
||||
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<K extends FieldSpecKind> = (typeof fieldSpecs)[K];
|
||||
export type FieldSpec = FieldSpecOf<FieldSpecKind>;
|
||||
|
||||
/** `Field<T>`: runtime field spec + phantom type of the expected value (number|string|boolean). */
|
||||
export interface Field<T> {
|
||||
spec: FieldSpec;
|
||||
/** Phantom, for type safety only; never set or read at runtime. */
|
||||
__fieldT?: T;
|
||||
}
|
||||
|
||||
export interface ToolLayoutGroup {
|
||||
/** Group title (an empty group is not rendered). */
|
||||
title?: string;
|
||||
/** Grid column count inside the group (default 1). */
|
||||
cols?: number;
|
||||
/** Schema field names belonging to the group. */
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface ToolSchemaLayout {
|
||||
/** Widget field groups. Unmentioned fields go to a trailing default group. */
|
||||
groups: ToolLayoutGroup[];
|
||||
}
|
||||
|
||||
export interface ToolSchemaMeta {
|
||||
/** Per-tool field layout (grouping/columns). */
|
||||
layout?: ToolSchemaLayout;
|
||||
/** Short tool label for the UI (optional). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ToolSchema<P> {
|
||||
fields: { [K in keyof P]: Field<P[K]> };
|
||||
layout?: ToolSchemaLayout;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const field = {
|
||||
number: (s: Omit<NumberSpec, "kind">): Field<number> => ({
|
||||
spec: { kind: "number", ...s },
|
||||
}),
|
||||
slider: (s: Omit<SliderSpec, "kind">): Field<number> => ({
|
||||
spec: { kind: "slider", ...s },
|
||||
}),
|
||||
color: (s: Omit<ColorSpec, "kind">): Field<string> => ({
|
||||
spec: { kind: "color", ...s },
|
||||
}),
|
||||
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
|
||||
spec: { kind: "select", ...s },
|
||||
}),
|
||||
segmented: <V extends string>(
|
||||
s: Omit<SegmentedSpec<V>, "kind">,
|
||||
): Field<V> => ({
|
||||
spec: { kind: "segmented", ...s },
|
||||
}),
|
||||
text: (s: Omit<TextSpec, "kind">): Field<string> => ({
|
||||
spec: { kind: "text", ...s },
|
||||
}),
|
||||
checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({
|
||||
spec: { kind: "checkbox", ...s },
|
||||
}),
|
||||
dimension: (s: {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
width: number;
|
||||
height: number;
|
||||
defaultFromSource?: boolean;
|
||||
maxFromSource?: boolean;
|
||||
lockAspectWith?: string;
|
||||
presets?: boolean;
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}): Field<Dimension> => ({
|
||||
spec: { kind: "dimension", ...s },
|
||||
}),
|
||||
colorPair: (s: {
|
||||
label?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Field<ColorPair> => ({
|
||||
spec: { kind: "color-pair", ...s },
|
||||
}),
|
||||
colors: (s: { label?: string; default: string[] }): Field<ColorList> => ({
|
||||
spec: { kind: "colors", ...s },
|
||||
}),
|
||||
offset: (s: {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}): Field<Offset> => ({
|
||||
spec: { kind: "offset", ...s },
|
||||
}),
|
||||
position9: (s: { label?: string; default: Position9 }): Field<Position9> => ({
|
||||
spec: { kind: "position9", ...s },
|
||||
}),
|
||||
fontStyle: (s: {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
size: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}): Field<FontStyle> => ({
|
||||
spec: { kind: "font-style", ...s },
|
||||
}),
|
||||
plate: (s: {
|
||||
label?: string;
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}): Field<Plate> => ({
|
||||
spec: { kind: "plate", ...s },
|
||||
}),
|
||||
gradient: (s: {
|
||||
label?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
angle: number;
|
||||
}): Field<Gradient> => ({
|
||||
spec: { kind: "gradient", ...s },
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a `ToolSchema<P>` from declared fields.
|
||||
* The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`.
|
||||
*/
|
||||
export function toolSchema<P>(
|
||||
fields: { [K in keyof P]: Field<P[K]> },
|
||||
meta?: ToolSchemaMeta,
|
||||
): ToolSchema<P> {
|
||||
return { fields, ...(meta ?? {}) };
|
||||
}
|
||||
|
||||
export interface ResolvedLayoutGroup {
|
||||
key: string;
|
||||
title?: string;
|
||||
cols: number;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export function resolveLayoutGroups<P>(
|
||||
schema: ToolSchema<P>,
|
||||
): ResolvedLayoutGroup[] {
|
||||
const all = Object.keys(schema.fields);
|
||||
const groups = schema.layout?.groups ?? [];
|
||||
const used: Record<string, true> = {};
|
||||
const named: ResolvedLayoutGroup[] = groups
|
||||
.map((group, index) => {
|
||||
const fields = group.fields.filter((fieldId) => {
|
||||
if (used[fieldId] || !(fieldId in schema.fields)) return false;
|
||||
used[fieldId] = true;
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
key: `${group.title ?? "group"}-${index}`,
|
||||
title: group.title,
|
||||
cols: Math.max(1, Math.trunc(group.cols ?? 1)),
|
||||
fields,
|
||||
} satisfies ResolvedLayoutGroup;
|
||||
})
|
||||
.filter((group) => group.fields.length > 0);
|
||||
const rest = all.filter((fieldId) => !used[fieldId]);
|
||||
if (rest.length > 0) {
|
||||
named.push({ key: "__default", cols: 1, fields: rest });
|
||||
}
|
||||
return named;
|
||||
}
|
||||
|
||||
/** Field cap: from source dimensions under maxFromSource, else static. */
|
||||
export function effectiveMax(
|
||||
spec: NumberSpec,
|
||||
source?: Dimension,
|
||||
): number | undefined {
|
||||
if (spec.maxFromSource && source) {
|
||||
return source[spec.maxFromSource] - (spec.maxMinus ?? 0);
|
||||
}
|
||||
return spec.max;
|
||||
}
|
||||
|
||||
function dimensionAxisMax(
|
||||
spec: DimensionSpec,
|
||||
axis: "width" | "height",
|
||||
source?: Dimension,
|
||||
): number {
|
||||
return spec.maxFromSource && source ? source[axis] : spec.max;
|
||||
}
|
||||
|
||||
/** Clamps maxFromSource fields to the current cap (source changed). */
|
||||
export function clampSourceAwareMaxes<P>(
|
||||
schema: ToolSchema<P>,
|
||||
params: Record<string, unknown>,
|
||||
source?: Dimension,
|
||||
): Record<string, unknown> {
|
||||
if (!source) return params;
|
||||
let changed = false;
|
||||
const out = { ...params };
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
if (spec.kind === "number" && spec.maxFromSource) {
|
||||
const v = out[key as string];
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) continue;
|
||||
const clamped = clamp(
|
||||
v,
|
||||
spec.min ?? -Infinity,
|
||||
effectiveMax(spec, source) ?? Infinity,
|
||||
);
|
||||
if (clamped !== v) {
|
||||
out[key as string] = clamped;
|
||||
changed = true;
|
||||
}
|
||||
} else if (spec.kind === "dimension" && spec.maxFromSource) {
|
||||
const v = out[key as string] as Partial<Dimension> | undefined;
|
||||
if (typeof v?.width !== "number" || typeof v?.height !== "number") {
|
||||
continue;
|
||||
}
|
||||
const width = clamp(
|
||||
v.width,
|
||||
spec.min ?? -Infinity,
|
||||
dimensionAxisMax(spec, "width", source),
|
||||
);
|
||||
const height = clamp(
|
||||
v.height,
|
||||
spec.min ?? -Infinity,
|
||||
dimensionAxisMax(spec, "height", source),
|
||||
);
|
||||
if (width !== v.width || height !== v.height) {
|
||||
out[key as string] = { width, height };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? out : params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute under lockAspect: the leading axis (last user edit) is kept,
|
||||
* the other is fitted to the source aspect.
|
||||
*/
|
||||
export function withAspectLock(
|
||||
next: Dimension,
|
||||
leading: "width" | "height",
|
||||
aspect: number,
|
||||
): Dimension {
|
||||
if (leading === "width") {
|
||||
return { ...next, height: Math.max(1, Math.round(next.width / aspect)) };
|
||||
}
|
||||
return { ...next, width: Math.max(1, Math.round(next.height * aspect)) };
|
||||
}
|
||||
|
||||
function resolveDimensionDefaults(
|
||||
spec: DimensionSpec,
|
||||
context?: SchemaContext,
|
||||
): Dimension {
|
||||
const source = spec.defaultFromSource ? context?.source : undefined;
|
||||
return {
|
||||
width: clamp(source?.width ?? spec.width, spec.min, spec.max),
|
||||
height: clamp(source?.height ?? spec.height, spec.min, spec.max),
|
||||
};
|
||||
}
|
||||
|
||||
/** Defaults from the schema: the single source of default values for the UI. */
|
||||
export function defaultSchemaParams<P>(
|
||||
schema: ToolSchema<P>,
|
||||
context?: SchemaContext,
|
||||
): Record<keyof P, unknown> {
|
||||
const out = {} as Record<keyof P, unknown>;
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
if (spec.kind === "dimension") {
|
||||
out[key] = resolveDimensionDefaults(spec, context);
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes defaults from a new source, preserving user values. Fields in
|
||||
* touched (edited manually) are not overwritten even with a source default;
|
||||
* otherwise loading a new file would wipe configured sizes.
|
||||
*/
|
||||
export function applySourceDefaults<P>(
|
||||
schema: ToolSchema<P>,
|
||||
values: Record<string, unknown>,
|
||||
context: SchemaContext,
|
||||
touched?: ReadonlySet<string>,
|
||||
): Record<keyof P, unknown> {
|
||||
const defaults = defaultSchemaParams(schema, context);
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
const fromSource = spec.kind === "dimension" && spec.defaultFromSource;
|
||||
if (fromSource && !touched?.has(key as string)) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
||||
defaults[key] = values[key as string];
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */
|
||||
export function sanitizeSchemaParams<P>(
|
||||
schema: ToolSchema<P>,
|
||||
values: Record<string, unknown>,
|
||||
context?: SchemaContext,
|
||||
): Record<keyof P, unknown> {
|
||||
const out = {} as Record<keyof P, unknown>;
|
||||
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;
|
||||
const max =
|
||||
spec.kind === "number"
|
||||
? effectiveMax(spec, context?.source)
|
||||
: spec.max;
|
||||
out[key] = clamp(n, spec.min ?? -Infinity, max ?? Infinity);
|
||||
break;
|
||||
}
|
||||
case "select":
|
||||
case "segmented":
|
||||
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<string, unknown>)
|
||||
: undefined;
|
||||
const defaults = resolveDimensionDefaults(spec, context);
|
||||
const w =
|
||||
typeof r?.width === "number" && Number.isFinite(r.width)
|
||||
? r.width
|
||||
: defaults.width;
|
||||
const h =
|
||||
typeof r?.height === "number" && Number.isFinite(r.height)
|
||||
? r.height
|
||||
: defaults.height;
|
||||
const sourceSize =
|
||||
spec.defaultFromSource && context?.source && w === 0 && h === 0
|
||||
? defaults
|
||||
: { width: w, height: h };
|
||||
out[key] = {
|
||||
width: clamp(
|
||||
sourceSize.width,
|
||||
spec.min,
|
||||
dimensionAxisMax(spec, "width", context?.source),
|
||||
),
|
||||
height: clamp(
|
||||
sourceSize.height,
|
||||
spec.min,
|
||||
dimensionAxisMax(spec, "height", context?.source),
|
||||
),
|
||||
};
|
||||
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;
|
||||
}
|
||||
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<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
: 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<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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { clamp } from "../core/math";
|
||||
import type { Dimension, DimensionSpec, NumberSpec, ToolSchema } from "./specs";
|
||||
|
||||
/** Field cap: from source dimensions under maxFromSource, else static. */
|
||||
export function effectiveMax(
|
||||
spec: NumberSpec,
|
||||
source?: Dimension,
|
||||
): number | undefined {
|
||||
if (spec.maxFromSource && source) {
|
||||
return source[spec.maxFromSource] - (spec.maxMinus ?? 0);
|
||||
}
|
||||
return spec.max;
|
||||
}
|
||||
|
||||
export function dimensionAxisMax(
|
||||
spec: DimensionSpec,
|
||||
axis: "width" | "height",
|
||||
source?: Dimension,
|
||||
): number {
|
||||
return spec.maxFromSource && source ? source[axis] : spec.max;
|
||||
}
|
||||
|
||||
/** Clamps maxFromSource fields to the current cap (source changed). */
|
||||
export function clampSourceAwareMaxes<P>(
|
||||
schema: ToolSchema<P>,
|
||||
params: Record<string, unknown>,
|
||||
source?: Dimension,
|
||||
): Record<string, unknown> {
|
||||
if (!source) return params;
|
||||
let changed = false;
|
||||
const out = { ...params };
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
if (spec.kind === "number" && spec.maxFromSource) {
|
||||
const v = out[key as string];
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) continue;
|
||||
const clamped = clamp(
|
||||
v,
|
||||
spec.min ?? -Infinity,
|
||||
effectiveMax(spec, source) ?? Infinity,
|
||||
);
|
||||
if (clamped !== v) {
|
||||
out[key as string] = clamped;
|
||||
changed = true;
|
||||
}
|
||||
} else if (spec.kind === "dimension" && spec.maxFromSource) {
|
||||
const v = out[key as string] as Partial<Dimension> | undefined;
|
||||
if (typeof v?.width !== "number" || typeof v?.height !== "number") {
|
||||
continue;
|
||||
}
|
||||
const width = clamp(
|
||||
v.width,
|
||||
spec.min ?? -Infinity,
|
||||
dimensionAxisMax(spec, "width", source),
|
||||
);
|
||||
const height = clamp(
|
||||
v.height,
|
||||
spec.min ?? -Infinity,
|
||||
dimensionAxisMax(spec, "height", source),
|
||||
);
|
||||
if (width !== v.width || height !== v.height) {
|
||||
out[key as string] = { width, height };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? out : params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute under lockAspect: the leading axis (last user edit) is kept,
|
||||
* the other is fitted to the source aspect.
|
||||
*/
|
||||
export function withAspectLock(
|
||||
next: Dimension,
|
||||
leading: "width" | "height",
|
||||
aspect: number,
|
||||
): Dimension {
|
||||
if (leading === "width") {
|
||||
return { ...next, height: Math.max(1, Math.round(next.width / aspect)) };
|
||||
}
|
||||
return { ...next, width: Math.max(1, Math.round(next.height * aspect)) };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { TextFont } from "../core/domText";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
import type {
|
||||
CheckboxSpec,
|
||||
ColorList,
|
||||
ColorPair,
|
||||
ColorSpec,
|
||||
Dimension,
|
||||
Field,
|
||||
FontStyle,
|
||||
Gradient,
|
||||
NumberSpec,
|
||||
Offset,
|
||||
Plate,
|
||||
SegmentedSpec,
|
||||
SelectSpec,
|
||||
SliderSpec,
|
||||
TextSpec,
|
||||
ToolSchema,
|
||||
ToolSchemaMeta,
|
||||
} from "./specs";
|
||||
|
||||
export const field = {
|
||||
number: (s: Omit<NumberSpec, "kind">): Field<number> => ({
|
||||
spec: { kind: "number", ...s },
|
||||
}),
|
||||
slider: (s: Omit<SliderSpec, "kind">): Field<number> => ({
|
||||
spec: { kind: "slider", ...s },
|
||||
}),
|
||||
color: (s: Omit<ColorSpec, "kind">): Field<string> => ({
|
||||
spec: { kind: "color", ...s },
|
||||
}),
|
||||
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
|
||||
spec: { kind: "select", ...s },
|
||||
}),
|
||||
segmented: <V extends string>(
|
||||
s: Omit<SegmentedSpec<V>, "kind">,
|
||||
): Field<V> => ({
|
||||
spec: { kind: "segmented", ...s },
|
||||
}),
|
||||
text: (s: Omit<TextSpec, "kind">): Field<string> => ({
|
||||
spec: { kind: "text", ...s },
|
||||
}),
|
||||
checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({
|
||||
spec: { kind: "checkbox", ...s },
|
||||
}),
|
||||
dimension: (s: {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
width: number;
|
||||
height: number;
|
||||
defaultFromSource?: boolean;
|
||||
maxFromSource?: boolean;
|
||||
lockAspectWith?: string;
|
||||
presets?: boolean;
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}): Field<Dimension> => ({
|
||||
spec: { kind: "dimension", ...s },
|
||||
}),
|
||||
colorPair: (s: {
|
||||
label?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Field<ColorPair> => ({
|
||||
spec: { kind: "color-pair", ...s },
|
||||
}),
|
||||
colors: (s: { label?: string; default: string[] }): Field<ColorList> => ({
|
||||
spec: { kind: "colors", ...s },
|
||||
}),
|
||||
offset: (s: {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}): Field<Offset> => ({
|
||||
spec: { kind: "offset", ...s },
|
||||
}),
|
||||
position9: (s: { label?: string; default: Position9 }): Field<Position9> => ({
|
||||
spec: { kind: "position9", ...s },
|
||||
}),
|
||||
fontStyle: (s: {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
size: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}): Field<FontStyle> => ({
|
||||
spec: { kind: "font-style", ...s },
|
||||
}),
|
||||
plate: (s: {
|
||||
label?: string;
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}): Field<Plate> => ({
|
||||
spec: { kind: "plate", ...s },
|
||||
}),
|
||||
gradient: (s: {
|
||||
label?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
angle: number;
|
||||
}): Field<Gradient> => ({
|
||||
spec: { kind: "gradient", ...s },
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a `ToolSchema<P>` from declared fields.
|
||||
* The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`.
|
||||
*/
|
||||
export function toolSchema<P>(
|
||||
fields: { [K in keyof P]: Field<P[K]> },
|
||||
meta?: ToolSchemaMeta,
|
||||
): ToolSchema<P> {
|
||||
return { fields, ...(meta ?? {}) };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./aspect";
|
||||
export * from "./field";
|
||||
export * from "./layout";
|
||||
export * from "./specs";
|
||||
export * from "./values";
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ToolSchema } from "./specs";
|
||||
|
||||
export interface ResolvedLayoutGroup {
|
||||
key: string;
|
||||
title?: string;
|
||||
cols: number;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export function resolveLayoutGroups<P>(
|
||||
schema: ToolSchema<P>,
|
||||
): ResolvedLayoutGroup[] {
|
||||
const all = Object.keys(schema.fields);
|
||||
const groups = schema.layout?.groups ?? [];
|
||||
const used: Record<string, true> = {};
|
||||
const named: ResolvedLayoutGroup[] = groups
|
||||
.map((group, index) => {
|
||||
const fields = group.fields.filter((fieldId) => {
|
||||
if (used[fieldId] || !(fieldId in schema.fields)) return false;
|
||||
used[fieldId] = true;
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
key: `${group.title ?? "group"}-${index}`,
|
||||
title: group.title,
|
||||
cols: Math.max(1, Math.trunc(group.cols ?? 1)),
|
||||
fields,
|
||||
} satisfies ResolvedLayoutGroup;
|
||||
})
|
||||
.filter((group) => group.fields.length > 0);
|
||||
const rest = all.filter((fieldId) => !used[fieldId]);
|
||||
if (rest.length > 0) {
|
||||
named.push({ key: "__default", cols: 1, fields: rest });
|
||||
}
|
||||
return named;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Typed parameter schema for the UI. Compile-time type binding (drift
|
||||
// protection): the phantom `Field.__fieldT` ties the runtime spec to the
|
||||
// expected value type, so the compiler catches Params/schema mismatches.
|
||||
|
||||
import type { TextFont } from "../core/domText";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
|
||||
/** Optional field of every spec: label key in the dictionary (see `fieldLabel`). */
|
||||
export interface FieldSpecBase {
|
||||
label?: string;
|
||||
/** Field visible only when another field equals a value (tool modes). */
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}
|
||||
|
||||
export interface NumberSpec extends FieldSpecBase {
|
||||
kind: "number";
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
/** Cap from source dimensions (axis); static max is the fallback. */
|
||||
maxFromSource?: "width" | "height";
|
||||
maxMinus?: number;
|
||||
}
|
||||
|
||||
export interface SliderSpec extends FieldSpecBase {
|
||||
kind: "slider";
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface ColorSpec extends FieldSpecBase {
|
||||
kind: "color";
|
||||
default: string;
|
||||
}
|
||||
|
||||
export interface SelectSpec<V extends string = string> extends FieldSpecBase {
|
||||
kind: "select";
|
||||
default: V;
|
||||
options: { value: V; label: string }[];
|
||||
}
|
||||
|
||||
/** Same as select but rendered as segment buttons (1-click choice). */
|
||||
export interface SegmentedSpec<
|
||||
V extends string = string,
|
||||
> extends FieldSpecBase {
|
||||
kind: "segmented";
|
||||
default: V;
|
||||
options: { value: V; label: string }[];
|
||||
}
|
||||
|
||||
export interface TextSpec extends FieldSpecBase {
|
||||
kind: "text";
|
||||
default: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface CheckboxSpec extends FieldSpecBase {
|
||||
kind: "checkbox";
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
/** Composite "dimensions" field: width + height as one object. */
|
||||
export interface Dimension {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Single list of tile presets (squares) for dimension fields. */
|
||||
export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const;
|
||||
|
||||
export interface DimensionSpec extends FieldSpecBase {
|
||||
kind: "dimension";
|
||||
/** Shared range for both dimensions. */
|
||||
min: number;
|
||||
max: number;
|
||||
width: number;
|
||||
height: number;
|
||||
defaultFromSource?: boolean;
|
||||
/** Per-axis cap from the matching source dimension. */
|
||||
maxFromSource?: boolean;
|
||||
/** Id of the "keep aspect ratio" checkbox field. */
|
||||
lockAspectWith?: string;
|
||||
/** Show quick-pick chips from SIZE_PRESETS. */
|
||||
presets?: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaContext {
|
||||
source?: Dimension;
|
||||
}
|
||||
|
||||
/** Color pair "from -> to" (gradients, two-color mapping, etc.). */
|
||||
export interface ColorPair {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ColorPairSpec extends FieldSpecBase {
|
||||
kind: "color-pair";
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Color list (palette): array of hex strings. */
|
||||
export type ColorList = string[];
|
||||
|
||||
export interface ColorListSpec extends FieldSpecBase {
|
||||
kind: "colors";
|
||||
default: string[];
|
||||
}
|
||||
|
||||
/** Shape/object offset from center: x + y in percent. */
|
||||
export interface Offset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface OffsetSpec extends FieldSpecBase {
|
||||
kind: "offset";
|
||||
/** Shared range for both axes (in percent). */
|
||||
min: number;
|
||||
max: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** 9-position grid (3x3); values match `Position9` from 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 extends FieldSpecBase {
|
||||
kind: "position9";
|
||||
default: Position9;
|
||||
}
|
||||
|
||||
/** Text caption style: font, size, bold, and color as one object. */
|
||||
export interface FontStyle {
|
||||
font: TextFont;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface FontStyleSpec extends FieldSpecBase {
|
||||
kind: "font-style";
|
||||
/** Font size range. */
|
||||
min: number;
|
||||
max: number;
|
||||
size: number;
|
||||
font: TextFont;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Backing plate under a text caption: on/off, color, and opacity. */
|
||||
export interface Plate {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface PlateSpec extends FieldSpecBase {
|
||||
kind: "plate";
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/** Color gradient: pair + angle in degrees (0: left-to-right, 90: top-to-bottom). */
|
||||
export interface Gradient {
|
||||
from: string;
|
||||
to: string;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export interface GradientSpec extends FieldSpecBase {
|
||||
kind: "gradient";
|
||||
from: string;
|
||||
to: string;
|
||||
/** Gradient direction: 0..360, angle in degrees. */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
/** kind -> field spec map: single source of `FieldSpecKind`/`FieldSpec`. */
|
||||
export const fieldSpecs = {
|
||||
number: {} as NumberSpec,
|
||||
slider: {} as SliderSpec,
|
||||
color: {} as ColorSpec,
|
||||
select: {} as SelectSpec,
|
||||
segmented: {} as SegmentedSpec,
|
||||
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<K extends FieldSpecKind> = (typeof fieldSpecs)[K];
|
||||
export type FieldSpec = FieldSpecOf<FieldSpecKind>;
|
||||
|
||||
/** `Field<T>`: runtime field spec + phantom type of the expected value (number|string|boolean). */
|
||||
export interface Field<T> {
|
||||
spec: FieldSpec;
|
||||
/** Phantom, for type safety only; never set or read at runtime. */
|
||||
__fieldT?: T;
|
||||
}
|
||||
|
||||
export interface ToolLayoutGroup {
|
||||
/** Group title (an empty group is not rendered). */
|
||||
title?: string;
|
||||
/** Grid column count inside the group (default 1). */
|
||||
cols?: number;
|
||||
/** Schema field names belonging to the group. */
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface ToolSchemaLayout {
|
||||
/** Widget field groups. Unmentioned fields go to a trailing default group. */
|
||||
groups: ToolLayoutGroup[];
|
||||
}
|
||||
|
||||
export interface ToolSchemaMeta {
|
||||
/** Per-tool field layout (grouping/columns). */
|
||||
layout?: ToolSchemaLayout;
|
||||
/** Short tool label for the UI (optional). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ToolSchema<P> {
|
||||
fields: { [K in keyof P]: Field<P[K]> };
|
||||
layout?: ToolSchemaLayout;
|
||||
label?: string;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { clamp } from "../core/math";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
import { dimensionAxisMax, effectiveMax } from "./aspect";
|
||||
import {
|
||||
POSITION9_VALUES,
|
||||
type Dimension,
|
||||
type DimensionSpec,
|
||||
type SchemaContext,
|
||||
type ToolSchema,
|
||||
} from "./specs";
|
||||
|
||||
function resolveDimensionDefaults(
|
||||
spec: DimensionSpec,
|
||||
context?: SchemaContext,
|
||||
): Dimension {
|
||||
const source = spec.defaultFromSource ? context?.source : undefined;
|
||||
return {
|
||||
width: clamp(source?.width ?? spec.width, spec.min, spec.max),
|
||||
height: clamp(source?.height ?? spec.height, spec.min, spec.max),
|
||||
};
|
||||
}
|
||||
|
||||
/** Defaults from the schema: the single source of default values for the UI. */
|
||||
export function defaultSchemaParams<P>(
|
||||
schema: ToolSchema<P>,
|
||||
context?: SchemaContext,
|
||||
): Record<keyof P, unknown> {
|
||||
const out = {} as Record<keyof P, unknown>;
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
if (spec.kind === "dimension") {
|
||||
out[key] = resolveDimensionDefaults(spec, context);
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes defaults from a new source, preserving user values. Fields in
|
||||
* touched (edited manually) are not overwritten even with a source default;
|
||||
* otherwise loading a new file would wipe configured sizes.
|
||||
*/
|
||||
export function applySourceDefaults<P>(
|
||||
schema: ToolSchema<P>,
|
||||
values: Record<string, unknown>,
|
||||
context: SchemaContext,
|
||||
touched?: ReadonlySet<string>,
|
||||
): Record<keyof P, unknown> {
|
||||
const defaults = defaultSchemaParams(schema, context);
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
const fromSource = spec.kind === "dimension" && spec.defaultFromSource;
|
||||
if (fromSource && !touched?.has(key as string)) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
||||
defaults[key] = values[key as string];
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */
|
||||
export function sanitizeSchemaParams<P>(
|
||||
schema: ToolSchema<P>,
|
||||
values: Record<string, unknown>,
|
||||
context?: SchemaContext,
|
||||
): Record<keyof P, unknown> {
|
||||
const out = {} as Record<keyof P, unknown>;
|
||||
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;
|
||||
const max =
|
||||
spec.kind === "number"
|
||||
? effectiveMax(spec, context?.source)
|
||||
: spec.max;
|
||||
out[key] = clamp(n, spec.min ?? -Infinity, max ?? Infinity);
|
||||
break;
|
||||
}
|
||||
case "select":
|
||||
case "segmented":
|
||||
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<string, unknown>)
|
||||
: undefined;
|
||||
const defaults = resolveDimensionDefaults(spec, context);
|
||||
const w =
|
||||
typeof r?.width === "number" && Number.isFinite(r.width)
|
||||
? r.width
|
||||
: defaults.width;
|
||||
const h =
|
||||
typeof r?.height === "number" && Number.isFinite(r.height)
|
||||
? r.height
|
||||
: defaults.height;
|
||||
const sourceSize =
|
||||
spec.defaultFromSource && context?.source && w === 0 && h === 0
|
||||
? defaults
|
||||
: { width: w, height: h };
|
||||
out[key] = {
|
||||
width: clamp(
|
||||
sourceSize.width,
|
||||
spec.min,
|
||||
dimensionAxisMax(spec, "width", context?.source),
|
||||
),
|
||||
height: clamp(
|
||||
sourceSize.height,
|
||||
spec.min,
|
||||
dimensionAxisMax(spec, "height", context?.source),
|
||||
),
|
||||
};
|
||||
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;
|
||||
}
|
||||
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<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
: 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<string, unknown>)
|
||||
: 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<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