mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-06 12:58:38 +00:00
feat(editor): 支持 update replace 整节点替换,源码编辑走 replace 模式
新增 replace 选项跳过 mergeWith 等变换,PropsPanel 源码保存时使用整节点覆盖,避免已删除字段被 merge 保留。
This commit is contained in:
parent
af157fcda9
commit
6f0a41f2db
@ -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#行为扩展):** 是
|
||||
|
||||
@ -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 {
|
||||
// 源码编辑器保存(无 eventData):style 原样保留,其中的空字符串视为用户主动清除该样式。
|
||||
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 } } : {}),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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: '' });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user