10.3 多选编辑与批量修改
10.1 和 10.2 都在处理「选中一个对象」的面板。但编辑器里选中往往不止一个——框选一片小怪、按类型全选、多选后统一改血量。多选编辑看着只是「把单选重复 N 遍」,实际多出两个单选没有的问题:该显示哪些字段,以及值不一致时怎么标。
这一节处理这两个问题的数据部分,并且把结果接到 9 章的命令栈上——批量改动不能是一个「直接改 N 个对象」的函数,而要是一份可撤销的批量命令。同样地,DOM 渲染与鼠标交互是 L3/L4,命令行不可复现;但公共属性计算、混合值判定、批量命令生成全是纯逻辑,本节真跑并测住。
公共属性与混合值
第一个问题是「显示哪些字段」。如果选中的对象都是 Health 类型,那三个字段全都能显示。但如果选中的是一堆不同类型的对象——一部分是 Health、一部分是 Enemy——就只能显示它们的公共属性:所有选中类型都有的那些字段。求公共属性就是对各类型的属性名集合求交集,并且按第一个类型的声明顺序排列,保证稳定。
第二个问题是「值不一致怎么标」。假设三个对象的 current 是 42 / 42 / 10。面板不能只显示一个数字——那会让人以为三个都是它。工业界通用的做法是显示一个混合占位符(常见是短横线或星号),表示「这几项不一致」。所以在面板数据里,一个字段的值不再是裸值,而是两种状态之一:
| 状态 | 含义 | 面板表现 |
|---|---|---|
single | 所有选中项该属性值全等 | 显示那个值 |
mixed | 存在不一致 | 显示占位符(如 —) |
判断「全等」时要注意 vec3:它是数组,=== 永远为假,必须逐元素比较。position 这种字段如果只比引用,任何两个对象都会被判成混合,面板上就永远是占位符了。
可运行产物:多选面板与批量命令
下面 src/editor/property/multi-select.ts 层级 L1,完整可编译。它含四件事:结构相等 equalValue、公共属性 commonFieldNames、值聚合 aggregate 与多选面板 computeMultiPanel,以及批量命令生成 buildBatchEdit。
// file: src/editor/property/multi-select.ts
// tier: L1
// verify: npx vitest run src/editor/property/multi-select.test.ts
//
// helio 编辑器:多选编辑——计算公共属性、识别混合值、生成批量编辑命令。
// 纯数据:不碰 DOM,也不直接改场景,只产出「批量命令描述」,
// 交给 9 章的命令栈去执行与撤销。
import type { TypeDescriptor, PropertyDescriptor, FieldType } from '../../core/type-registry.ts';
import { readProperty } from '../../core/type-registry.ts';
/** 多选下的一个值:要么全部一致,要么是混合(并记录样本数)。 */
export type MultiValue<T> =
| { readonly kind: 'single'; readonly value: T }
| { readonly kind: 'mixed'; readonly count: number };
/** 多选面板里的一个字段。 */
export interface MultiField {
readonly name: string;
readonly type: FieldType;
readonly value: MultiValue<unknown>;
}
/** 多选面板描述数据。 */
export interface MultiPanel {
readonly typeName: string;
readonly total: number;
readonly fields: readonly MultiField[];
}
/** 一个选中对象:它的类型描述符 + 实例。 */
export interface Selection {
readonly desc: TypeDescriptor;
readonly obj: Record<string, unknown>;
}
/** 结构相等:标量按 ===,数组逐元素(vec3 用得到)。 */
export function equalValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => equalValue(v, b[i]));
}
return false;
}
/** 多个描述符的公共属性名:按第一个描述符的顺序求交集。 */
export function commonFieldNames(descs: readonly TypeDescriptor[]): string[] {
if (descs.length === 0) return [];
const sets = descs.map((d) => new Set(d.properties.map((p) => p.name)));
return descs[0].properties.map((p) => p.name).filter((n) => sets.every((s) => s.has(n)));
}
/** 从一个名字在多个选中项里找到第一个能提供它的属性描述符。 */
function findProp(sel: Selection, name: string): PropertyDescriptor | undefined {
return sel.desc.properties.find((p) => p.name === name);
}
/** 聚合一个属性在多个选中项上的值:全等 → single,否则 mixed。 */
export function aggregate(values: readonly unknown[]): MultiValue<unknown> {
if (values.length === 0) return { kind: 'mixed', count: 0 };
const first = values[0];
const same = values.every((v) => equalValue(v, first));
return same ? { kind: 'single', value: first } : { kind: 'mixed', count: values.length };
}
/** 计算多选面板:公共属性 + 每个属性的聚合值。 */
export function computeMultiPanel(selections: readonly Selection[]): MultiPanel {
const names = commonFieldNames(selections.map((s) => s.desc));
const fields: MultiField[] = [];
for (const name of names) {
const values: unknown[] = [];
let type: FieldType = 'f32';
for (const sel of selections) {
const prop = findProp(sel, name);
if (!prop) continue;
type = prop.type;
values.push(readProperty(sel.obj, prop));
}
fields.push({ name, type, value: aggregate(values) });
}
const typeName = selections.length > 0 ? selections[0].desc.name : '';
return { typeName, total: selections.length, fields };
}
/** 单条属性改动。target 是选中项下标,before/after 供命令栈做撤销。 */
export interface PropertyEdit {
readonly target: number;
readonly property: string;
readonly before: unknown;
readonly after: unknown;
}
/** 批量编辑命令描述:一个标签 + 一组改动。 */
export interface BatchEditCommand {
readonly label: string;
readonly edits: readonly PropertyEdit[];
}
/**
* 生成批量编辑命令:把 property 在全部选中项上设成 next。
* 已经是目标值的项被跳过——批量命令只包含真正变化的项,撤销栈因此干净。
*/
export function buildBatchEdit(
selections: readonly Selection[],
property: string,
next: unknown,
): BatchEditCommand {
const edits: PropertyEdit[] = [];
for (let i = 0; i < selections.length; i++) {
const prop = findProp(selections[i], property);
if (!prop) continue;
const before = readProperty(selections[i].obj, prop);
if (equalValue(before, next)) continue;
edits.push({ target: i, property, before, after: next });
}
return { label: `设置 ${selections.length} 个对象的 ${property}`, edits };
}
/** 把命令应用到普通对象上(演示用;真实场景走命令栈的 apply/undo)。 */
export function applyBatch(selections: readonly Selection[], cmd: BatchEditCommand): void {
for (const e of cmd.edits) selections[e.target].obj[e.property] = e.after;
}
两个取舍值得点出。第一,批量命令只装「真正变化」的项。如果三个对象里已经有两个是目标值,buildBatchEdit 只生成一条 edit。这样撤销栈干净、序列化出来的命令也小——批量命令不该包含无意义的改动。第二,每条 edit 都带 before 和 after。这正是 9 章命令栈做撤销所需的全部信息:撤销就是把 before 写回去。buildBatchEdit 不执行任何改动,它只描述改动。
真实输出:多选 Health
下面 src/apps/editor-multiselect-demo.ts 选中三个 Health,其中 current 不一致、max 一致、invulnerable 不一致,看面板怎么标;再生成一条批量命令并应用。
// file: src/apps/editor-multiselect-demo.ts
// tier: L1
// verify: node src/apps/editor-multiselect-demo.ts
//
// 多选公共属性、混合值判定与批量命令生成——10.3 的可运行产物。
import { TypeRegistry } from '../core/type-registry.ts';
import { GENERATED_TYPES } from '../generated/type-registry.gen.ts';
import { computeMultiPanel, buildBatchEdit, applyBatch, type Selection } from '../editor/property/multi-select.ts';
const reg = new TypeRegistry();
reg.registerAll(GENERATED_TYPES);
const health = reg.get('Health')!;
const selections: Selection[] = [
{ desc: health, obj: { current: 42, max: 100, invulnerable: false } },
{ desc: health, obj: { current: 42, max: 100, invulnerable: false } },
{ desc: health, obj: { current: 10, max: 100, invulnerable: true } },
];
const panel = computeMultiPanel(selections);
console.log(`multi-panel: ${panel.typeName}, total=${panel.total}`);
for (const f of panel.fields) {
const v = f.value.kind === 'single' ? JSON.stringify(f.value.value) : `<mixed x${f.value.count}>`;
console.log(` ${f.name.padEnd(13)} = ${v}`);
}
const cmd = buildBatchEdit(selections, 'current', 100);
console.log(`command: "${cmd.label}" edits=${cmd.edits.length}`);
for (const e of cmd.edits) console.log(` #${e.target} ${e.property}: ${JSON.stringify(e.before)} -> ${JSON.stringify(e.after)}`);
applyBatch(selections, cmd);
console.log('after apply:', selections.map((s) => s.obj.current).join(', '));
本机 node src/apps/editor-multiselect-demo.ts 的真实输出:
multi-panel: Health, total=3
current = <mixed x3>
max = 100
invulnerable = <mixed x3>
command: "设置 3 个对象的 current" edits=3
#0 current: 42 -> 100
#1 current: 42 -> 100
#2 current: 10 -> 100
after apply: 100, 100, 100
三种情况一眼可辨:current 是 42 / 42 / 10,标成 <mixed x3>;max 三个都是 100,直接显示 100;invulnerable 两个 false 一个 true,也是混合。批量命令的 label 用了选中总数 3,但 edits 也是 3——因为这次三个对象的 current 都不是 100,没有可跳过的项。把其中一个改成 100 再跑,edits 会掉到 2,这就是「跳过无变化项」在真实输出里的样子。
批量应用到场景:L3/L4,命令行不可复现
面板的渲染与交互是另一层。下面 src/editor/property/multi-view.ts 层级 L3——依赖 document,本节需在浏览器中手动验证,命令行不可复现(仅通过 tsc 类型检查)。多选真正被鼠标框选触发属于 L4,更无法在命令行里复现。
// file: src/editor/property/multi-view.ts
// tier: L3
//
// 多选面板的 DOM 渲染:混合值显示占位、批量改动的交互入口。
// 依赖 document,命令行不可复现,需在浏览器中手动验证。
import type { MultiPanel, MultiField, Selection } from './multi-select.ts';
import { applyBatch, buildBatchEdit } from './multi-select.ts';
/** 混合值的占位符——多选时该项值不一致。 */
export const MIXED_PLACEHOLDER = '—';
function renderField(f: MultiField): HTMLElement {
const row = document.createElement('div');
row.className = 'field-row';
const label = document.createElement('label');
label.textContent = f.name;
const value = document.createElement('span');
if (f.value.kind === 'single') {
value.textContent = String(f.value.value);
} else {
value.textContent = MIXED_PLACEHOLDER;
value.classList.add('is-mixed');
}
row.append(label, value);
return row;
}
/** 把多选面板渲染进宿主节点:标题是「类型名 × 选中数」。 */
export function renderMultiPanel(panel: MultiPanel, host: HTMLElement): void {
host.replaceChildren();
const title = document.createElement('header');
title.className = 'multi-header';
title.textContent = `${panel.typeName} × ${panel.total}`;
host.append(title);
for (const f of panel.fields) host.append(renderField(f));
}
/**
* 批量改动的交互入口:生成批量命令并交给命令栈执行。
* 真实场景里 applyBatch 会被 9 章命令栈的 apply/undo 取代,
* 此处为演示直接落到对象上。
*/
export function commitBatch(
selections: readonly Selection[],
property: string,
next: unknown,
): boolean {
const cmd = buildBatchEdit(selections, property, next);
if (cmd.edits.length === 0) return false;
applyBatch(selections, cmd);
return true;
}
renderField 里那个分支就是混合值的落点:single 显示值,mixed 显示占位符并加一个 is-mixed 类供 CSS 置灰。commitBatch 是交互入口——它调用 buildBatchEdit 生成命令,而不是自己遍历对象去改。这一点很关键:把批量改动包成命令,才能接进 9 章的命令栈做撤销;如果在这里直接 for 循环改对象,多选改动就变成不可撤销的了。注释里的 applyBatch 是演示替身,真实代码换成命令栈的 apply。
测试
上面那些结论都被单测盯住了。挑关键断言看:
// snippet: src/editor/property/multi-select.test.ts 节选
expect(commonFieldNames([HEALTH, ENEMY])).toEqual(['current']); // 混合类型只留交集
expect(aggregate([[1, 2, 3], [1, 2, 3]])).toEqual({ kind: 'single', value: [1, 2, 3] }); // vec3 逐元素
expect(aggregate([[1, 2, 3], [1, 2, 4]])).toEqual({ kind: 'mixed', count: 2 });
// 已是目标值的项被跳过
const cmd = buildBatchEdit([{ desc: HEALTH, obj: { current: 50 } },
{ desc: HEALTH, obj: { current: 50 } }], 'current', 50);
expect(cmd.edits).toHaveLength(0);
本机 npx vitest run src/editor/property/multi-select.test.ts 是 7 个用例全过(tsc --noEmit 零错误)。用例覆盖三类:公共属性求交集、vec3 逐元素判等(避免误判混合)、批量命令跳过无变化项。
几个坑:
- 用
===比较vec3。 数组永远不是同一个引用,===恒为假,任何两个对象的position都会被判成混合。必须逐元素比。 - 把「公共属性」当成「第一个对象的属性」。 混选不同类型时,第一个类型独有的字段不该出现在面板上——用户改它,另一个类型的对象会收到一个不存在的属性。求交集才对。
- 批量命令里塞进无变化的项。 撤销栈会因此多出冗余步骤,而且撤销时「恢复原值」实际没变,行为诡异。生成命令时跳过
before === after的项。 - 在面板里直接改对象、不生成命令。 多选改动一旦绕过命令栈,就再也撤销不了。面板只生成命令,执行交给 9 章的命令栈。
小结
- 多选面板要回答两个问题:显示哪些字段(公共属性 = 各类型属性名的交集)、值不一致怎么标(聚合为
single/mixed)。 - 判等必须结构比较:
vec3逐元素比,否则永远误判混合。 - 批量改动产出的是可撤销的命令(
BatchEditCommand),每条带before/after,并跳过无变化项;面板只生成命令,执行交给 9 章命令栈。 - 面板渲染与鼠标框选是 L3/L4,需在浏览器中手动验证,命令行不可复现。
编辑器这四章到此收口:8 章文档模型、9 章命令栈、10 章属性面板。下一章换到编辑器的另一半——视口:gizmo 拖拽、拾取射线,以及资产浏览与关卡编辑。
阅读导航:上一节:10.2 编辑器控件与类型映射 · 下一节:11.1 gizmo 与拾取射线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。