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()` инкрементом
|
`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),
|
Файл (769 строк) разрезан на директорию `web/src/lib/registry-schema/`:
|
||||||
(б) builder-DSL `field.*` (:273-360), (в) layout engine `resolveLayoutGroups`
|
`specs.ts` (типы полей, `fieldSpecs`, `ToolSchema`), `field.ts` (builder DSL +
|
||||||
(:380-406), (г) семантику значений `defaultSchemaParams`/`sanitizeSchemaParams`
|
`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`,
|
||||||
(:503-770), (д) aspect-lock геометрию `withAspectLock`/`clampSourceAwareMaxes`
|
`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (дефолты и санитизация);
|
||||||
(:434-489). Каждая из (в)-(д) — отдельный модуль.
|
`index.ts` — баррель, пути импортов не менялись. `docs/architecture.md` (разделы
|
||||||
|
1, 4) обновлён. JSDoc в `specs.ts` сжат до однострочников — лимит плотности
|
||||||
|
комментариев (`conventions/comment-format`, 15%).
|
||||||
|
|
||||||
## Med
|
## Med
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ web/src/lib/
|
|||||||
registry/ # источник истины: инструменты и страницы
|
registry/ # источник истины: инструменты и страницы
|
||||||
registry/tools/ # Tool: реализации (id), без адресов и текстов
|
registry/tools/ # Tool: реализации (id), без адресов и текстов
|
||||||
registry/pages/ # Page: адрес, тексты, категория, список шагов
|
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
|
catalog.ts # группировка страниц и счётчики для /list-tools
|
||||||
categories.ts # id/названия категорий
|
categories.ts # id/названия категорий
|
||||||
tool-icons.ts # slug страницы → иконка
|
tool-icons.ts # slug страницы → иконка
|
||||||
@@ -170,7 +173,11 @@ registry/{tools,pages}/*.ts
|
|||||||
|
|
||||||
## 4. Схема параметров
|
## 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`,
|
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
|
||||||
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
|
`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