feat(editor): 支持 update replace 整节点替换,源码编辑走 replace 模式

新增 replace 选项跳过 mergeWith 等变换,PropsPanel 源码保存时使用整节点覆盖,避免已删除字段被 merge 保留。
This commit is contained in:
roymondchen 2026-08-04 19:13:33 +08:00
parent af157fcda9
commit 6f0a41f2db
5 changed files with 122 additions and 26 deletions

View File

@ -480,6 +480,8 @@ editorService.highlight("text_123");
- {`MNode`} config 新的节点
- `{Object}` data 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` replace 是否整节点替换(默认 false。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
@ -498,6 +500,8 @@ editorService.highlight("text_123");
当被更新节点正好在当前选中列表中时state 会自动同步到新的节点引用,无需调用方处理
当被更新节点正好是当前页面时state.page 也会同步到新的节点引用;更新非当前页面(不同 ID时不会把编辑器切到该页
默认会将传入配置与现有节点深合并(保留未传入的字段)。若需要完整 DSL 覆盖(如源码编辑写回),传入 `replace: true`
:::
## update
@ -512,6 +516,7 @@ editorService.highlight("text_123");
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- `{boolean}` replace 是否整节点替换(默认 false。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点;适用于源码编辑、完整 DSL 回写等场景
- `{Object}` invalidInfo 启用 [enablePropsFormValidate](./props.md#enablepropsformvalidate) 时,属性面板提交携带的校验错误信息,在写入历史记录之前落库,使历史快照与本次变更对齐
- `{Id}` id 节点 id
- `{'props' | 'style'}` source 错误来源:属性表单 / 样式表单
@ -546,6 +551,12 @@ editorService.highlight("text_123");
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
:::
:::tip
**`replace: true` 整节点替换:** 默认 `update` 会将传入配置与现有节点深合并,未传入的字段会保留。
传入 `replace: true` 后跳过合并与布局相关变换,直接用传入配置覆盖,传入对象中不存在的字段会被删除。
属性面板局部改动请保持默认;源码编辑 / 完整 DSL 回写等场景使用 `replace: true`
:::
## sort
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是

View File

@ -158,14 +158,34 @@ const submit = async (
v.id = values.value.id;
}
const newValue: MNode = {
...v,
style: {},
};
// MForm @change eventData changeRecords
// CodeEditor @save saveCode eventData
const historySource = eventData ? 'props' : 'code';
// replace merge
const replace = historySource === 'code';
if (v.style) {
// doUpdate mergeWith
if (eventData) {
let newValue: MNode;
if (replace) {
if (source === 'style') {
// { style } style
newValue = {
...values.value,
id: v.id || values.value.id,
style: { ...(v.style || {}) },
};
} else {
// DSL
newValue = { ...v };
}
} else {
newValue = {
...v,
style: {},
};
if (v.style) {
// doUpdate mergeWith
// DSL
// changeRecords
Object.entries(v.style).forEach(([key, value]) => {
@ -174,24 +194,18 @@ const submit = async (
}
});
eventData.changeRecords?.forEach((record) => {
eventData?.changeRecords?.forEach((record) => {
if (record.propPath?.startsWith('style') && record.value === '') {
setValueByKeyPath(record.propPath, record.value, newValue);
}
});
} else {
// eventDatastyle
newValue.style = { ...v.style };
}
}
// MForm @change eventData changeRecords
// CodeEditor @save saveCode eventData
const historySource = eventData ? 'props' : 'code';
editorService.update(newValue, {
changeRecords: eventData?.changeRecords,
historySource,
replace,
// error editorService
// CodeEditor invalidInfo editorService update
...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}),

View File

@ -720,7 +720,11 @@ class Editor extends BaseService {
public async doUpdate(
config: MNode,
{ changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
{
changeRecords = [],
historySource,
replace = false,
}: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource; replace?: boolean } = {},
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -733,9 +737,14 @@ class Editor extends BaseService {
const node = toRaw(info.node);
let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout直接用传入配置整节点替换
let newConfig = replace
? cloneDeep(toRaw(config))
: await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
if (!replace) {
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
}
if (!newConfig.type) throw new Error('配置缺少type值');
@ -756,10 +765,12 @@ class Editor extends BaseService {
if (!parentNodeItems || typeof index === 'undefined' || index === -1) throw new Error('更新的节点未找到');
const newLayout = await this.getLayout(newConfig);
const layout = await this.getLayout(node);
if (Array.isArray(newConfig.items) && newLayout !== layout) {
newConfig = setChildrenLayout(newConfig as MContainer, newLayout);
if (!replace) {
const newLayout = await this.getLayout(newConfig);
const layout = await this.getLayout(node);
if (Array.isArray(newConfig.items) && newLayout !== layout) {
newConfig = setChildrenLayout(newConfig as MContainer, newLayout);
}
}
parentNodeItems[index] = newConfig;
@ -798,6 +809,7 @@ class Editor extends BaseService {
* @param data.changeRecordList form config changeRecords
* @param data.doNotPushHistory false
* @param data.historyDescription undo/redo
* @param data.replace true mergeWith / toggleFixedPosition / setChildrenLayout false
* @returns
*/
public async update(
@ -808,6 +820,11 @@ class Editor extends BaseService {
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
/**
* true
* DSL false merge
*/
replace?: boolean;
/**
*
* 使 undo/redo
@ -823,6 +840,7 @@ class Editor extends BaseService {
changeRecords,
historyDescription,
historySource,
replace = false,
invalidInfo,
} = data;
@ -833,7 +851,7 @@ class Editor extends BaseService {
const updateData = await Promise.all(
nodes.map((node, index) => {
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
return this.doUpdate(node, { changeRecords: recordsForNode, historySource, replace });
}),
);
@ -1348,6 +1366,7 @@ class Editor extends BaseService {
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
replace?: boolean;
} = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;

View File

@ -92,10 +92,15 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
onClick: () =>
emit('submit', { id: 'n1', style: { color: 'red' } }, { changeRecords: [] }, new Error('校验失败详情')),
}),
// 模拟 CodeEditor 源码保存:仅传 values无 eventData、无 error(对应 saveCode 路径)
// 模拟属性面板 CodeEditor 源码保存:完整节点,无 eventData(对应 saveCode 路径)
h('button', {
class: 'code-save-btn',
onClick: () => emit('submit', { id: 'n1', style: { color: 'red', width: '' } }),
onClick: () => emit('submit', { id: 'n1', type: 'text', style: { color: 'red', width: '' } }),
}),
// 模拟样式面板 CodeEditor 源码保存:仅 { style }FormPanel codeValueKey=style
h('button', {
class: 'style-code-save-btn',
onClick: () => emit('submit', { style: { color: 'blue', width: '' } }),
}),
h('button', { class: 'submit-err-btn', onClick: () => emit('submit-error', new Error('e')) }),
h('button', { class: 'form-err-btn', onClick: () => emit('form-error', new Error('e')) }),
@ -140,7 +145,7 @@ beforeEach(() => {
return null;
});
editorService.get.mockImplementation((k: string) => {
if (k === 'node') return { id: 'n1', type: 'text' };
if (k === 'node') return { id: 'n1', type: 'text', text: 'hello', style: { width: 100, color: 'old' } };
if (k === 'nodes') return [{ id: 'n1' }];
return null;
});
@ -237,6 +242,8 @@ describe('PropsPanel', () => {
expect(options.invalidInfo).toBeUndefined();
// historySource 应为 code
expect(options.historySource).toBe('code');
// 属性面板源码保存应整节点替换,避免 merge 保留已删除字段
expect(options.replace).toBe(true);
});
test('CodeEditor 源码保存时 style 中的空字符串值被保留(表示清除该样式)', async () => {
@ -249,6 +256,36 @@ describe('PropsPanel', () => {
expect(calledNode.style.width).toBe('');
});
test('样式面板源码保存:将新 style 替换到原节点后整节点 replace', async () => {
showStylePanel.value = true;
const wrapper = mount(PropsPanel, { props: {} as any });
await new Promise((r) => setTimeout(r, 0));
// 第二个 FormPanel 为样式面板
const styleCodeBtns = wrapper.findAll('.style-code-save-btn');
expect(styleCodeBtns.length).toBe(2);
await styleCodeBtns[1].trigger('click');
const [calledNode, options] = editorService.update.mock.calls[0] as any;
expect(options.replace).toBe(true);
expect(options.historySource).toBe('code');
// 原节点其它字段保留
expect(calledNode.id).toBe('n1');
expect(calledNode.type).toBe('text');
expect(calledNode.text).toBe('hello');
// style 被源码内容整段替换(旧 width:100 / color:old 不再 merge 保留)
expect(calledNode.style).toEqual({ color: 'blue', width: '' });
});
test('表单字段编辑不使用 replace', async () => {
const wrapper = mount(PropsPanel, { props: {} as any });
await new Promise((r) => setTimeout(r, 0));
await wrapper.find('.submit-btn').trigger('click');
const options = (editorService.update.mock.calls[0] as any)[1];
expect(options.replace).toBe(false);
});
test('mounted 事件 emit', async () => {
const wrapper = mount(PropsPanel, { props: {} as any });
await wrapper.find('.mounted-btn').trigger('click');

View File

@ -734,6 +734,21 @@ describe('update', () => {
expect(node?.text).toBe('text');
});
test('replace=true 时整节点替换,不 merge 保留旧字段', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
// 默认 merge未传入的 style.width 会保留
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'merged' });
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270);
// replace直接用传入配置覆盖旧字段style被去掉
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'replaced' }, { replace: true });
const node = editorService.getNodeById(NodeId.NODE_ID);
expect(node?.text).toBe('replaced');
expect(node?.style).toBeUndefined();
});
test('没有id', async () => {
try {
await editorService.update({ type: 'text', text: 'text', id: '' });