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

View File

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

View File

@ -46,6 +46,8 @@
<<< @/../packages/schema/src/index.ts#Id{ts} <<< @/../packages/schema/src/index.ts#Id{ts}
<<< @/../packages/schema/src/index.ts#MNode{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 ## undo
- **返回:** - **返回:**
@ -62,7 +70,8 @@
- **详情:** - **详情:**
撤销当前操作 撤销当前操作。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``oldNode` 取值做局部回滚;否则用 `oldNode` 整节点替换。
## redo ## redo
@ -71,7 +80,8 @@
- **详情:** - **详情:**
恢复到下一步 恢复到下一步。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``newNode` 取值做局部重做;否则用 `newNode` 整节点替换。
## pushCodeBlock ## pushCodeBlock
@ -80,11 +90,14 @@
- `{Object} payload` - `{Object} payload`
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null` - `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null` - `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换
::: details 查看 CodeBlockStepValue 及关联类型定义 ::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts} <<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts} <<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
::: :::
- **返回:** - **返回:**
@ -158,9 +171,12 @@
- `{Object} payload` - `{Object} payload`
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null` - `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null`
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null` - `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换
::: details 查看 DataSourceStepValue 及关联类型定义 ::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts} <<< @/../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 type { CodeBlockContent } from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design'; import { tMagicMessage } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue'; import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
import type { Services } from '@editor/type'; import type { Services } from '@editor/type';
@ -61,10 +62,12 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
codeBlockService.deleteCodeDslByIds([key]); codeBlockService.deleteCodeDslByIds([key]);
}; };
const submitCodeBlockHandler = async (values: CodeBlockContent) => { const submitCodeBlockHandler = async (values: CodeBlockContent, eventData?: ContainerChangeEventData) => {
if (!codeId.value) return; if (!codeId.value) return;
await codeBlockService.setCodeDslById(codeId.value, values); await codeBlockService.setCodeDslById(codeId.value, values, {
changeRecords: eventData?.changeRecords,
});
codeBlockEditorRef.value?.hide(); codeBlockEditorRef.value?.hide();
}; };

View File

@ -115,19 +115,21 @@ export const useStage = (stageOptions: StageOptions) => {
} }
// 多选拖动 / 多选缩放:所有元素整批走一次 update避免历史栈被切成 N 条 // 多选拖动 / 多选缩放:所有元素整批走一次 update避免历史栈被切成 N 条
// changeRecordList 与 configs 同序,每个节点保留自己的 records
// 不能把多个节点的 records 合并到同一个数组里,否则 doUpdate / nodeUpdateHandler 会把别的节点的 propPath 当成自己的。
const configs: MNode[] = []; const configs: MNode[] = [];
const changeRecordsAll: ReturnType<typeof buildChangeRecords> = []; const changeRecordList: ReturnType<typeof buildChangeRecords>[] = [];
ev.data.forEach((data) => { ev.data.forEach((data) => {
const id = getIdFromEl()(data.el); const id = getIdFromEl()(data.el);
if (!id) return; if (!id) return;
const { style = {} } = data; const { style = {} } = data;
configs.push({ id, style }); configs.push({ id, style });
changeRecordsAll.push(...buildChangeRecords(style, 'style')); changeRecordList.push(buildChangeRecords(style, 'style'));
}); });
if (configs.length === 0) return; if (configs.length === 0) return;
editorService.update(configs, { changeRecords: changeRecordsAll }); editorService.update(configs, { changeRecordList });
}); });
stage.on('sort', (ev: SortEventData) => { 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 type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
import { Target, Watcher } 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 editorService from '@editor/services/editor';
import historyService from '@editor/services/history'; import historyService from '@editor/services/history';
@ -94,15 +94,16 @@ class CodeBlock extends BaseService {
* @param {Id} id id * @param {Id} id id
* @param {CodeBlockContent} codeConfig * @param {CodeBlockContent} codeConfig
* @param options * @param options
* @param options.changeRecords form propPath/value /
* @param options.doNotPushHistory false * @param options.doNotPushHistory false
* @returns {void} * @returns {void}
*/ */
public async setCodeDslById( public async setCodeDslById(
id: Id, id: Id,
codeConfig: Partial<CodeBlockContent>, codeConfig: Partial<CodeBlockContent>,
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}, { changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
): Promise<void> { ): 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 {CodeBlockContent} codeConfig
* @param {boolean} force true * @param {boolean} force true
* @param options * @param options
* @param options.changeRecords form propPath/value /
* @param options.doNotPushHistory false * @param options.doNotPushHistory false
* @returns {void} * @returns {void}
*/ */
@ -119,7 +121,7 @@ class CodeBlock extends BaseService {
id: Id, id: Id,
codeConfig: Partial<CodeBlockContent>, codeConfig: Partial<CodeBlockContent>,
force = true, force = true,
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}, { changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
): void { ): void {
const codeDsl = this.getCodeDsl(); const codeDsl = this.getCodeDsl();
@ -150,7 +152,7 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]); const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) { if (!doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent }); historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords });
} }
this.emit('addOrUpdate', id, codeDsl[id]); this.emit('addOrUpdate', id, codeDsl[id]);

View File

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

View File

@ -23,7 +23,7 @@ import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions }
import { NodeType } from '@tmagic/core'; import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form'; import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage'; 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 BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props'; import propsService from '@editor/services//props';
@ -658,21 +658,33 @@ class Editor extends BaseService {
* update后会触发依赖收集stage.update方法 * update后会触发依赖收集stage.update方法
* @param config id信息 * @param config id信息
* @param data * @param data
* @param data.changeRecords form * @param data.changeRecords form 使 changeRecordList
* @param data.changeRecordList form config changeRecords
* @param data.doNotPushHistory false * @param data.doNotPushHistory false
* @returns * @returns
*/ */
public async update( public async update(
config: MNode | MNode[], config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {}, data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
} = {},
): Promise<MNode | MNode[]> { ): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp(); this.captureSelectionBeforeOp();
const { doNotPushHistory = false } = data; const { doNotPushHistory = false, changeRecordList, changeRecords } = data;
const nodes = Array.isArray(config) ? config : [config]; 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) { if (updateData[0].oldNode?.type !== NodeType.ROOT) {
const curNodes = this.get('nodes'); const curNodes = this.get('nodes');
@ -685,6 +697,9 @@ class Editor extends BaseService {
updatedItems: updateData.map((d) => ({ updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode), oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)), newNode: cloneDeep(toRaw(d.newNode)),
// 每个节点单独保留自己的 changeRecords便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
})), })),
}, },
{ name: pageForOp?.name || '', id: pageForOp!.id }, { name: pageForOp?.name || '', id: pageForOp!.id },
@ -1272,7 +1287,26 @@ class Editor extends BaseService {
} }
case 'update': { case 'update': {
const items = step.updatedItems ?? []; 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) { if (configs.length) {
await this.update(configs, { doNotPushHistory: true }); await this.update(configs, { doNotPushHistory: true });
} }

View File

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

View File

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

View File

@ -119,7 +119,16 @@ describe('useCodeBlockEdit', () => {
const hook = mountHook({ setCodeDslById }); const hook = mountHook({ setCodeDslById });
hook.codeId.value = 'id1'; hook.codeId.value = 'id1';
await hook.submitCodeBlockHandler({ name: 'b' } as any); await hook.submitCodeBlockHandler({ name: 'b' } as any);
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }); expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: undefined });
expect(hideMock).toHaveBeenCalled(); 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', () => ({ 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(() => []), getGuideLineFromCache: vi.fn(() => []),
})); }));
@ -211,6 +213,11 @@ describe('useStage', () => {
{ id: 'c1', style: { width: 10 } }, { id: 'c1', style: { width: 10 } },
{ id: 'c2', style: { width: 20 } }, { 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 事件', () => { test('sort 事件', () => {

View File

@ -209,4 +209,24 @@ describe('CodeBlockService - 历史记录接入', () => {
await codeBlockService.deleteCodeDslByIds(['ghost']); await codeBlockService.deleteCodeDslByIds(['ghost']);
expect(historyService.canUndoCodeBlock('ghost')).toBe(false); 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'); dataSource.remove('ghost');
expect(historyService.canUndoDataSource('ghost')).toBe(false); 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); const redoNode = editorService.getNodeById(NodeId.NODE_ID);
expect(redoNode?.id).toBeUndefined(); 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);
});
}); });