10.2 编辑器控件与类型映射
10.1 把面板展开成了一串 PanelField,但每个字段旁边还是空的——它只说「这里有 position,类型 vec3,值 [0, 1.5, 0]」,没说该画成什么。这一节补上这一步:把字段类型翻译成控件种类。
这一步看起来琐碎,其实是编辑器「自动化」程度的分水岭。如果每加一个字段都要人指定「这个用数字框、那个用勾选框」,那面板还是半自动的。而字段类型是反射描述符已经给的确定信息——bool 就该是勾选框,vec3 就该展开成三个分量。所以这一步也应该由映射表自动完成,人只在个别特例上插手。
同样先划清界线:映射与解析是纯逻辑(L1,可跑可测),真实控件的创建是 L3(需在浏览器中手动验证,命令行不可复现)。本节把两者分成两个文件,各管各的。
一张映射表
映射的核心是一张「字段类型 → 控件种类」的表。helio 的字段类型来自 16 章的 FieldType,一共 7 个;控件种类是编辑器侧定义的语义标签:
| 字段类型 | 控件种类 | 说明 |
|---|---|---|
f32 / f64 | number | 浮点数字框,默认步进 0.01 |
i32 / u32 | number | 整数数字框,步进 1 |
bool | checkbox | 勾选框 |
vec3 | vector | 展开成 3 个数字框 |
handle | object-ref | 对象引用选择器 |
表里没出现 enum——因为 16 章的 FieldType 里根本没有枚举类型。枚举是编辑器元数据加上的:一个 i32 字段,只要元数据给了 enumValues,就该显示成下拉框。所以映射不是「一个类型对一个控件」的死表,而是带优先级的规则:先看有没有枚举候选,再退回类型表。
控件种类是语义,不是标签名。number 具体渲染成 <input type="number"> 还是 <input type="range">,是 L3 层的事;映射层只负责说「这是一个数值控件」。这样换一套 UI 风格时,映射逻辑一行不用改。
可运行产物:控件映射与注册表
下面 src/editor/property/widget-map.ts 层级 L1,完整可编译。它含三样东西:默认映射表与 defaultWidget、按字段补齐参数的 describeWidget、以及允许覆写的 WidgetRegistry。
// file: src/editor/property/widget-map.ts
// tier: L1
// verify: npx vitest run src/editor/property/widget-map.test.ts
//
// helio 编辑器:字段类型 → 控件种类的映射表,以及可覆写的控件注册表。
// 这一层只决定「用哪种控件、带什么参数」,不创建任何 DOM 节点;
// 控件的真实创建在 L3 的 DOM 胶水里(见 10.2 末节)。
import type { FieldType } from '../../core/type-registry.ts';
import type { PanelField } from './panel-model.ts';
/** 编辑器控件种类。控件种类是「语义」,具体标签由 L3 层决定(input / select / ...)。 */
export type WidgetKind =
| 'number'
| 'slider'
| 'checkbox'
| 'text'
| 'dropdown'
| 'vector'
| 'object-ref';
/** 控件描述:种类 + 该种类需要的参数。 */
export interface WidgetSpec {
readonly kind: WidgetKind;
readonly options?: readonly string[];
readonly min?: number;
readonly max?: number;
readonly step?: number;
readonly components?: number;
}
/** 字段类型 → 默认控件种类。整数类型步进为 1,浮点默认 0.01。 */
const DEFAULT_WIDGET: Record<FieldType, WidgetKind> = {
f32: 'number',
f64: 'number',
i32: 'number',
u32: 'number',
bool: 'checkbox',
handle: 'object-ref',
vec3: 'vector',
};
/** 分量数表:vec3 展开成三个数字框。 */
const VEC_COMPONENTS: Partial<Record<FieldType, number>> = { vec3: 3 };
/** 整数类型用 1 步进,浮点用 0.01。 */
function defaultStep(type: FieldType): number {
return type === 'i32' || type === 'u32' ? 1 : 0.01;
}
/** 默认映射:只看字段类型。 */
export function defaultWidget(type: FieldType): WidgetKind {
return DEFAULT_WIDGET[type];
}
/** 给定控件种类,补齐该种类需要的参数。 */
export function specForKind(kind: WidgetKind, field: PanelField): WidgetSpec {
if (kind === 'number' || kind === 'slider') {
return { kind, min: field.min, max: field.max, step: field.step ?? defaultStep(field.type) };
}
if (kind === 'vector') {
return { kind, components: VEC_COMPONENTS[field.type] ?? 3 };
}
return { kind };
}
/** 由字段描述出控件:枚举优先,其次按类型给参数。 */
export function describeWidget(field: PanelField): WidgetSpec {
if (field.enumValues && field.enumValues.length > 0) {
return { kind: 'dropdown', options: field.enumValues };
}
return specForKind(defaultWidget(field.type), field);
}
/** 可覆写的控件注册表:按类型或按「属性名」改写默认映射。 */
export class WidgetRegistry {
readonly #byType = new Map<FieldType, WidgetKind>();
readonly #byName = new Map<string, WidgetKind>();
/** 覆写某个字段类型的默认控件(如把 f32 默认改成滑块)。 */
overrideType(type: FieldType, kind: WidgetKind): void {
this.#byType.set(type, kind);
}
/** 覆写某个属性名的控件(优先级最高,用于个别特殊字段)。 */
overrideName(name: string, kind: WidgetKind): void {
this.#byName.set(name, kind);
}
/** 解析优先级:属性名覆写 > 枚举 > 类型覆写 > 默认。 */
resolve(field: PanelField): WidgetSpec {
const named = this.#byName.get(field.name);
if (named) return { kind: named };
if (field.enumValues && field.enumValues.length > 0) {
return { kind: 'dropdown', options: field.enumValues };
}
const byType = this.#byType.get(field.type);
if (byType) return specForKind(byType, field);
return describeWidget(field);
}
}
/** 供文档与调试用:导出一份默认映射表的副本。 */
export function defaultWidgetTable(): ReadonlyArray<{ type: FieldType; kind: WidgetKind }> {
return (Object.keys(DEFAULT_WIDGET) as FieldType[]).map((type) => ({
type,
kind: DEFAULT_WIDGET[type],
}));
}
优先级那一行是本节最容易写错的地方。resolve 的顺序是属性名覆写 > 枚举 > 类型覆写 > 默认:先看有没有针对这个属性名的特例,再看是不是枚举,再看这个字段类型有没有被整体改过,最后才回落到默认表。顺序错了会出怪现象——比如枚举被类型覆写抢先,下拉框变成了数字框。
真实输出:从类型到控件
下面 src/apps/editor-widget-demo.ts 打印默认映射表,再对 Health 的三个字段解析控件。它故意把 f32 整体覆写成滑块,看 max 上的元数据范围怎么带进控件参数。
// file: src/apps/editor-widget-demo.ts
// tier: L1
// verify: node src/apps/editor-widget-demo.ts
//
// 把面板字段映射成控件描述——10.2 的可运行产物。
import { TypeRegistry } from '../core/type-registry.ts';
import { GENERATED_TYPES } from '../generated/type-registry.gen.ts';
import { buildPanelModel } from '../editor/property/panel-model.ts';
import { WidgetRegistry, defaultWidgetTable } from '../editor/property/widget-map.ts';
const reg = new TypeRegistry();
reg.registerAll(GENERATED_TYPES);
console.log('default widget table:');
for (const row of defaultWidgetTable()) console.log(` ${row.type.padEnd(7)} -> ${row.kind}`);
const health = reg.get('Health')!;
const model = buildPanelModel(health, { current: 42, max: 100, invulnerable: false }, {
'Health.max': { min: 1, max: 999 },
});
const widgets = new WidgetRegistry();
widgets.overrideType('f32', 'slider');
for (const f of model.fields) {
const spec = widgets.resolve(f);
console.log(` ${f.name.padEnd(13)} -> ${spec.kind} ${JSON.stringify(spec)}`);
}
本机 node src/apps/editor-widget-demo.ts 的真实输出:
default widget table:
f32 -> number
f64 -> number
i32 -> number
u32 -> number
bool -> checkbox
handle -> object-ref
vec3 -> vector
current -> slider {"kind":"slider","step":0.01}
max -> slider {"kind":"slider","min":1,"max":999,"step":0.01}
invulnerable -> checkbox {"kind":"checkbox"}
三个字段各说明一件事。current 被 overrideType('f32', 'slider') 改成了滑块,步进默认 0.01;max 同样是滑块,但把元数据里的 min: 1, max: 999 带进了参数——控件参数来自字段,控件种类来自映射,两路信息在这里汇合;invulnerable 是 bool,没被覆写,仍是 checkbox。类型覆写只影响 f32,其余类型不受牵连。
创建控件:L3,命令行不可复现
映射产出的是「语义描述」,把它变成真实节点又是另一层。下面 src/editor/property/widget-factory.ts 层级 L3——依赖 document,本节需在浏览器中手动验证,命令行不可复现(仅通过 tsc 类型检查)。
// file: src/editor/property/widget-factory.ts
// tier: L3
//
// 按控件描述创建真实 DOM 控件。依赖 document,命令行不可复现,
// 需在浏览器中手动验证(本文件仅通过 tsc 类型检查)。
import type { PanelField } from './panel-model.ts';
import type { WidgetSpec } from './widget-map.ts';
/** 一个已创建的控件:根节点 + 读写值的两个方法。 */
export interface WidgetHandle {
readonly root: HTMLElement;
setValue(value: unknown): void;
getValue(): unknown;
}
function elementId(field: PanelField): string {
return `fld-${field.name}`;
}
function numberWidget(field: PanelField, spec: WidgetSpec, slider: boolean): WidgetHandle {
const input = document.createElement('input');
input.type = slider ? 'range' : 'number';
input.id = elementId(field);
if (spec.min !== undefined) input.min = String(spec.min);
if (spec.max !== undefined) input.max = String(spec.max);
input.step = String(spec.step ?? 0.01);
return {
root: input,
setValue: (v) => { input.value = String(v); },
getValue: () => Number(input.value),
};
}
function checkboxWidget(field: PanelField): WidgetHandle {
const input = document.createElement('input');
input.type = 'checkbox';
input.id = elementId(field);
return {
root: input,
setValue: (v) => { input.checked = Boolean(v); },
getValue: () => input.checked,
};
}
function dropdownWidget(field: PanelField, options: readonly string[]): WidgetHandle {
const select = document.createElement('select');
select.id = elementId(field);
for (const opt of options) {
const o = document.createElement('option');
o.value = opt;
o.textContent = opt;
select.append(o);
}
return {
root: select,
setValue: (v) => { select.value = String(v); },
getValue: () => select.value,
};
}
function vectorWidget(field: PanelField, components: number): WidgetHandle {
const root = document.createElement('div');
root.className = 'vec-widget';
const inputs: HTMLInputElement[] = [];
for (let i = 0; i < components; i++) {
const input = document.createElement('input');
input.type = 'number';
input.step = '0.01';
input.dataset.component = String(i);
inputs.push(input);
root.append(input);
}
return {
root,
setValue: (v) => {
const arr = Array.isArray(v) ? v : [];
inputs.forEach((input, i) => { input.value = String(arr[i] ?? 0); });
},
getValue: () => inputs.map((input) => Number(input.value)),
};
}
/** 按控件描述创建 DOM 控件。控件种类决定用哪个标签。 */
export function createWidget(field: PanelField, spec: WidgetSpec): WidgetHandle {
switch (spec.kind) {
case 'checkbox':
return checkboxWidget(field);
case 'dropdown':
return dropdownWidget(field, spec.options ?? []);
case 'vector':
return vectorWidget(field, spec.components ?? 3);
case 'slider':
return numberWidget(field, spec, true);
default:
return numberWidget(field, spec, false);
}
}
这里 createWidget 的 switch 就是「语义 → 标签」的翻译:checkbox 造勾选框、vector 造三分量组、slider 造 range。numberWidget 接收 slider 布尔量决定 type,所以数字框与滑块共用一个实现。vector 的 getValue 把三个输入拼回数组——读回来的仍是 vec3 的值形状,控件再花哨也不改变数据契约。
测试
映射与优先级都被单测盯住了。挑关键断言看:
// snippet: src/editor/property/widget-map.test.ts 节选
expect(defaultWidget('bool')).toBe('checkbox');
expect(defaultWidget('vec3')).toBe('vector');
expect(defaultWidgetTable()).toHaveLength(7); // 覆盖全部 7 个字段类型
const reg = new WidgetRegistry();
reg.overrideType('f32', 'slider');
reg.overrideName('speed', 'text');
expect(reg.resolve(speed).kind).toBe('text'); // 属性名覆写优先
expect(reg.resolve(grounded).kind).toBe('checkbox'); // 未覆写类型不变
本机 npx vitest run src/editor/property/widget-map.test.ts 是 8 个用例全过(tsc --noEmit 零错误)。用例覆盖三类:默认映射逐类型正确、枚举优先于类型、覆写优先级(属性名 > 类型 > 默认)。
几个坑:
- 把控件种类当成标签名。
'number'是语义,不是<input>。混了之后,想换 UI 框架就得改映射层,分层就白做了。 - 优先级写反。 若「类型覆写」排在「枚举」前面,一个被覆写成
number的i32枚举字段会丢掉下拉框。顺序是契约,要用用例钉死。 - 整数与浮点用同一个步进。 整数字段步进
0.01会让用户能输入1.5,而底层是i32——值被截断,用户看到的是「输入了但没生效」。整数步进必须是1。
小结
- 字段类型是反射给的确定信息,控件种类应由映射表自动推导,人只在特例上插手。
- 映射是带优先级的规则:属性名覆写 > 枚举 > 类型覆写 > 默认;枚举由编辑器元数据附加,不在
FieldType里。 WidgetSpec里种类来自映射、参数来自字段(min/max/step/components),两路信息在resolve汇合。- 真实控件创建是 L3,需在浏览器中手动验证,命令行不可复现。
单选的映射解决了。可编辑器里选中往往不止一个对象——下一节处理多选:哪些字段该显示、值不一致时怎么标、改一次怎么落到所有对象。
阅读导航:上一节:10.1 反射驱动的属性面板 · 下一节:10.3 多选编辑与批量修改 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。