feat(editor): 历史记录接入 changeRecords,undo/redo 按 propPath 局部更新

- 节点 / 数据源 / 代码块的 history step 增加 changeRecords 字段

- editor.update / dataSource.update / codeBlock.setCodeDslById(Sync) 透传 changeRecords 入历史

- applyHistoryOp 的 update 分支:携带 changeRecords 时,按 propPath 从 oldNode/newNode 取值

  构造最小 patch 走 update,不冲掉同节点上其它无关变更;缺省退化为整节点替换

  (覆盖 sort/moveLayer/拖动等纯快照场景)

- editor.update 增加 changeRecordList 形参,多节点场景每个节点单独保留 records;

  use-stage 多选拖动 / 缩放改用 changeRecordList,避免 records 在多节点间共享

- use-code-block-edit.submitCodeBlockHandler 透传 form changeRecords

- 同步更新 editor / dataSource / codeBlock / history service 文档
This commit is contained in:
roymondchen 2026-05-28 16:28:35 +08:00
parent 4c855ba50b
commit 09558fa027
16 changed files with 226 additions and 24 deletions

View File

@ -49,8 +49,13 @@
- `{string | number}` id 代码块id
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
- `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
- `{Promise<void>}`
@ -65,6 +70,7 @@
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
- `{boolean}` force 是否强制写入,默认 `true`;为 `false` 时若同 id 已存在则跳过
- `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:**
@ -77,6 +83,7 @@
::: tip
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock`
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。
传入的 `changeRecords` 会一同写进 step撤销/重做时调用方可据此按 `propPath` 局部回放。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
:::

View File

@ -352,7 +352,8 @@ console.log(newDs.id); // 自动生成的id
::: tip
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema`
均为对应 schema 的更新记录。传入 `doNotPushHistory: true` 可跳过写入历史栈。
均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step撤销/重做时调用方可据此按
`propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。
:::
- **示例:**

View File

@ -456,9 +456,14 @@ editorService.highlight("text_123");
- **参数:**
- {`MNode` | `MNode`[]} config 新的节点或节点集合
- `{Object}` data 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录
- {`ChangeRecord`[]} changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 `changeRecordList`
- {`ChangeRecord`[][]} changeRecordList 多节点 form 端变更记录列表,按 `config` 数组同序对应每个节点;优先级高于 `changeRecords`
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合
@ -474,6 +479,16 @@ editorService.highlight("text_123");
编辑器内部更新组件都是调用update来实现的update除了更新操作外还会记录历史堆还会更新[代码块](../../guide/advanced/code-block.md)关系链。
:::
:::tip
**多节点场景必须使用 `changeRecordList`**:每个节点应保留自己独立的 records不能把多个节点的
records 合并到同一个 `changeRecords` 数组里,否则 `doUpdate` / 依赖收集 / 历史回放都会按错误的
`propPath` 处理。
写入历史时,每个节点的 records 会单独保存到 `updatedItems[i].changeRecords`;撤销/重做时若有
records则仅按 `propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;缺省
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
:::
## sort
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是

View File

@ -46,6 +46,8 @@
<<< @/../packages/schema/src/index.ts#Id{ts}
<<< @/../packages/schema/src/index.ts#MNode{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
@ -55,6 +57,12 @@
添加一条历史记录
::: tip
`opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
:::
## undo
- **返回:**
@ -62,7 +70,8 @@
- **详情:**
撤销当前操作
撤销当前操作。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``oldNode` 取值做局部回滚;否则用 `oldNode` 整节点替换。
## redo
@ -71,7 +80,8 @@
- **详情:**
恢复到下一步
恢复到下一步。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``newNode` 取值做局部重做;否则用 `newNode` 整节点替换。
## pushCodeBlock
@ -80,11 +90,14 @@
- `{Object} payload`
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
@ -158,9 +171,12 @@
- `{Object} payload`
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null`
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**

View File

@ -3,6 +3,7 @@ import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent } from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
import type { Services } from '@editor/type';
@ -61,10 +62,12 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
codeBlockService.deleteCodeDslByIds([key]);
};
const submitCodeBlockHandler = async (values: CodeBlockContent) => {
const submitCodeBlockHandler = async (values: CodeBlockContent, eventData?: ContainerChangeEventData) => {
if (!codeId.value) return;
await codeBlockService.setCodeDslById(codeId.value, values);
await codeBlockService.setCodeDslById(codeId.value, values, {
changeRecords: eventData?.changeRecords,
});
codeBlockEditorRef.value?.hide();
};

View File

@ -115,19 +115,21 @@ export const useStage = (stageOptions: StageOptions) => {
}
// 多选拖动 / 多选缩放:所有元素整批走一次 update避免历史栈被切成 N 条
// changeRecordList 与 configs 同序,每个节点保留自己的 records
// 不能把多个节点的 records 合并到同一个数组里,否则 doUpdate / nodeUpdateHandler 会把别的节点的 propPath 当成自己的。
const configs: MNode[] = [];
const changeRecordsAll: ReturnType<typeof buildChangeRecords> = [];
const changeRecordList: ReturnType<typeof buildChangeRecords>[] = [];
ev.data.forEach((data) => {
const id = getIdFromEl()(data.el);
if (!id) return;
const { style = {} } = data;
configs.push({ id, style });
changeRecordsAll.push(...buildChangeRecords(style, 'style'));
changeRecordList.push(buildChangeRecords(style, 'style'));
});
if (configs.length === 0) return;
editorService.update(configs, { changeRecords: changeRecordsAll });
editorService.update(configs, { changeRecordList });
});
stage.on('sort', (ev: SortEventData) => {

View File

@ -22,7 +22,7 @@ import type { Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
import { Target, Watcher } from '@tmagic/core';
import type { TableColumnConfig } from '@tmagic/form';
import type { ChangeRecord, TableColumnConfig } from '@tmagic/form';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
@ -94,15 +94,16 @@ class CodeBlock extends BaseService {
* @param {Id} id id
* @param {CodeBlockContent} codeConfig
* @param options
* @param options.changeRecords form propPath/value /
* @param options.doNotPushHistory false
* @returns {void}
*/
public async setCodeDslById(
id: Id,
codeConfig: Partial<CodeBlockContent>,
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {},
{ changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
): Promise<void> {
this.setCodeDslByIdSync(id, codeConfig, true, { doNotPushHistory });
this.setCodeDslByIdSync(id, codeConfig, true, { changeRecords, doNotPushHistory });
}
/**
@ -112,6 +113,7 @@ class CodeBlock extends BaseService {
* @param {CodeBlockContent} codeConfig
* @param {boolean} force true
* @param options
* @param options.changeRecords form propPath/value /
* @param options.doNotPushHistory false
* @returns {void}
*/
@ -119,7 +121,7 @@ class CodeBlock extends BaseService {
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {},
{ changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
): void {
const codeDsl = this.getCodeDsl();
@ -150,7 +152,7 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent });
historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords });
}
this.emit('addOrUpdate', id, codeDsl[id]);

View File

@ -153,6 +153,7 @@ class DataSource extends BaseService {
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
});
}

View File

@ -23,7 +23,7 @@ import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions }
import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage';
import { getNodeInfo, getNodePath, isPage, isPageFragment } from '@tmagic/utils';
import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils';
import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props';
@ -658,21 +658,33 @@ class Editor extends BaseService {
* update后会触发依赖收集stage.update方法
* @param config id信息
* @param data
* @param data.changeRecords form
* @param data.changeRecords form 使 changeRecordList
* @param data.changeRecordList form config changeRecords
* @param data.doNotPushHistory false
* @returns
*/
public async update(
config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
} = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const { doNotPushHistory = false } = data;
const { doNotPushHistory = false, changeRecordList, changeRecords } = data;
const nodes = Array.isArray(config) ? config : [config];
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
// 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
// 否则同一份 changeRecords 会被复用到每个节点上nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
const updateData = await Promise.all(
nodes.map((node, index) => {
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
return this.doUpdate(node, { changeRecords: recordsForNode });
}),
);
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
const curNodes = this.get('nodes');
@ -685,6 +697,9 @@ class Editor extends BaseService {
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
// 每个节点单独保留自己的 changeRecords便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
})),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
@ -1272,7 +1287,26 @@ class Editor extends BaseService {
}
case 'update': {
const items = step.updatedItems ?? [];
const configs = items.map(({ oldNode, newNode }) => cloneDeep(reverse ? oldNode : newNode));
// 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
// 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
const configs = items.map(({ oldNode, newNode, changeRecords }) => {
if (changeRecords?.length) {
const sourceForValues = reverse ? oldNode : newNode;
// 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
// 后续 update -> mergeWith 会与现有节点深合并patch 中未涉及的字段不会被改动。
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(sourceForValues);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patch);
}
return patch;
}
return cloneDeep(reverse ? oldNode : newNode);
});
if (configs.length) {
await this.update(configs, { doNotPushHistory: true });
}

View File

@ -20,6 +20,7 @@ import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import type { CodeBlockStepValue, DataSourceStepValue, HistoryState, StepValue } from '@editor/type';
import { UndoRedo } from '@editor/utils/undo-redo';
@ -101,6 +102,7 @@ class History extends BaseService {
* - oldContent = nullnewContent =
* - oldContent / newContent
* - newContent = nulloldContent =
* - `changeRecords` form / propPath 退
* - codeBlockService
*/
public pushCodeBlock(
@ -108,6 +110,7 @@ class History extends BaseService {
payload: {
oldContent: CodeBlockContent | null;
newContent: CodeBlockContent | null;
changeRecords?: ChangeRecord[];
},
): CodeBlockStepValue | null {
if (!codeBlockId) return null;
@ -116,6 +119,7 @@ class History extends BaseService {
id: codeBlockId,
oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null,
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
};
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
@ -132,6 +136,7 @@ class History extends BaseService {
payload: {
oldSchema: DataSourceSchema | null;
newSchema: DataSourceSchema | null;
changeRecords?: ChangeRecord[];
},
): DataSourceStepValue | null {
if (!dataSourceId) return null;
@ -140,6 +145,7 @@ class History extends BaseService {
id: dataSourceId,
oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null,
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
};
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);

View File

@ -637,8 +637,13 @@ export interface StepValue {
indexMap?: Record<string, number>;
/** opType 'remove': 被删除的节点及其位置信息 */
removedItems?: { node: MNode; parentId: Id; index: number }[];
/** opType 'update': 变更前后的节点快照 */
updatedItems?: { oldNode: MNode; newNode: MNode }[];
/**
* opType 'update':
*
* `changeRecords` form propPath/value / propPath
* / 退
*/
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[];
}
// #endregion StepValue
@ -656,6 +661,11 @@ export interface CodeBlockStepValue {
oldContent: CodeBlockContent | null;
/** 变更后的代码块内容,删除时为 null */
newContent: CodeBlockContent | null;
/**
* form propPath/value / propPath
* 退/ changeRecords
*/
changeRecords?: ChangeRecord[];
}
// #endregion CodeBlockStepValue
@ -673,6 +683,11 @@ export interface DataSourceStepValue {
oldSchema: DataSourceSchema | null;
/** 变更后的数据源 schema删除时为 null */
newSchema: DataSourceSchema | null;
/**
* form propPath/value / propPath
* 退 schema / changeRecords
*/
changeRecords?: ChangeRecord[];
}
// #endregion DataSourceStepValue

View File

@ -119,7 +119,16 @@ describe('useCodeBlockEdit', () => {
const hook = mountHook({ setCodeDslById });
hook.codeId.value = 'id1';
await hook.submitCodeBlockHandler({ name: 'b' } as any);
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' });
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: undefined });
expect(hideMock).toHaveBeenCalled();
});
test('submitCodeBlockHandler - 透传 eventData.changeRecords 给 setCodeDslById', async () => {
const setCodeDslById = vi.fn();
const hook = mountHook({ setCodeDslById });
hook.codeId.value = 'id1';
const records = [{ propPath: 'name', value: 'b' }];
await hook.submitCodeBlockHandler({ name: 'b' } as any, { changeRecords: records } as any);
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: records });
});
});

View File

@ -84,7 +84,9 @@ vi.mock('@editor/services/ui', () => ({
}));
vi.mock('@editor/utils/editor', () => ({
buildChangeRecords: vi.fn(() => []),
buildChangeRecords: vi.fn((style: Record<string, any>, basePath: string) =>
Object.entries(style ?? {}).map(([k, v]) => ({ propPath: `${basePath}.${k}`, value: v })),
),
getGuideLineFromCache: vi.fn(() => []),
}));
@ -211,6 +213,11 @@ describe('useStage', () => {
{ id: 'c1', style: { width: 10 } },
{ id: 'c2', style: { width: 20 } },
]);
// changeRecordList 与 configs 同序,每个节点独立保有自己的 records不能合并为一个数组
expect(callArgs[1].changeRecordList).toHaveLength(2);
expect(callArgs[1].changeRecordList[0]).toEqual([{ propPath: 'style.width', value: 10 }]);
expect(callArgs[1].changeRecordList[1]).toEqual([{ propPath: 'style.width', value: 20 }]);
expect(callArgs[1].changeRecords).toBeUndefined();
});
test('sort 事件', () => {

View File

@ -209,4 +209,24 @@ describe('CodeBlockService - 历史记录接入', () => {
await codeBlockService.deleteCodeDslByIds(['ghost']);
expect(historyService.canUndoCodeBlock('ghost')).toBe(false);
});
test('setCodeDslByIdSync - 携带 changeRecords 时写入历史 step', async () => {
historyService.reset();
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any, true, {
changeRecords: [{ propPath: 'name', value: 'A2' }],
});
const step = historyService.undoCodeBlock('a');
expect(step?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
});
test('setCodeDslByIdSync - 不传 changeRecords 时 step.changeRecords 为 undefined', async () => {
historyService.reset();
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
expect(step?.changeRecords).toBeUndefined();
});
});

View File

@ -164,4 +164,25 @@ describe('DataSource service - 历史记录接入', () => {
dataSource.remove('ghost');
expect(historyService.canUndoDataSource('ghost')).toBe(false);
});
test('update - 携带 changeRecords 时写入历史 step', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any, {
changeRecords: [{ propPath: 'title', value: 'b' }],
});
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
});
test('update - 不传 changeRecords 时 step.changeRecords 为 undefined', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toBeUndefined();
});
});

View File

@ -667,4 +667,47 @@ describe('undo redo', () => {
const redoNode = editorService.getNodeById(NodeId.NODE_ID);
expect(redoNode?.id).toBeUndefined();
});
test('update 携带 changeRecords 时undo/redo 仅回滚/重做对应 propPath不冲掉同节点其它字段', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
// 先携带 changeRecords 改 width
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', style: { width: 500 } },
{ changeRecords: [{ propPath: 'style.width', value: 500 }] },
);
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(500);
// 在 undo 之前再追加一个不入历史的字段(模拟"同节点上其它无关变更"undo 不应把它冲掉
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', style: { width: 500, height: 80 } },
{ doNotPushHistory: true },
);
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.height).toBe(80);
await editorService.undo();
const afterUndo = editorService.getNodeById(NodeId.NODE_ID);
// width 回退height 因为不在 changeRecords 内不会被局部 patch 覆盖
expect(afterUndo?.style?.width).toBe(270);
expect(afterUndo?.style?.height).toBe(80);
await editorService.redo();
const afterRedo = editorService.getNodeById(NodeId.NODE_ID);
expect(afterRedo?.style?.width).toBe(500);
expect(afterRedo?.style?.height).toBe(80);
});
test('update 不带 changeRecords 时退化为整节点替换', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', style: { width: 600 } });
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(600);
await editorService.undo();
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270);
});
});