From 09558fa0273af0b7d25b4338a8ea56810b09bb1c Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 28 May 2026 16:28:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=8E=A5=E5=85=A5=20changeRecords=EF=BC=8Cundo/redo?= =?UTF-8?q?=20=E6=8C=89=20propPath=20=E5=B1=80=E9=83=A8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 节点 / 数据源 / 代码块的 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 文档 --- docs/api/editor/codeBlockServiceMethods.md | 7 +++ docs/api/editor/dataSourceServiceMethods.md | 3 +- docs/api/editor/editorServiceMethods.md | 17 ++++++- docs/api/editor/historyServiceMethods.md | 20 +++++++- .../editor/src/hooks/use-code-block-edit.ts | 7 ++- packages/editor/src/hooks/use-stage.ts | 8 ++-- packages/editor/src/services/codeBlock.ts | 12 +++-- packages/editor/src/services/dataSource.ts | 1 + packages/editor/src/services/editor.ts | 46 ++++++++++++++++--- packages/editor/src/services/history.ts | 6 +++ packages/editor/src/type.ts | 19 +++++++- .../unit/hooks/use-code-block-edit.spec.ts | 11 ++++- .../editor/tests/unit/hooks/use-stage.spec.ts | 9 +++- .../tests/unit/services/codeBlock.spec.ts | 20 ++++++++ .../tests/unit/services/dataSource.spec.ts | 21 +++++++++ .../editor/tests/unit/services/editor.spec.ts | 43 +++++++++++++++++ 16 files changed, 226 insertions(+), 24 deletions(-) diff --git a/docs/api/editor/codeBlockServiceMethods.md b/docs/api/editor/codeBlockServiceMethods.md index 592aa97a..9316f6a3 100644 --- a/docs/api/editor/codeBlockServiceMethods.md +++ b/docs/api/editor/codeBlockServiceMethods.md @@ -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}` @@ -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` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。 ::: diff --git a/docs/api/editor/dataSourceServiceMethods.md b/docs/api/editor/dataSourceServiceMethods.md index bf5c2e4d..2b8e84e7 100644 --- a/docs/api/editor/dataSourceServiceMethods.md +++ b/docs/api/editor/dataSourceServiceMethods.md @@ -352,7 +352,8 @@ console.log(newDs.id); // 自动生成的id ::: tip 更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema` - 均为对应 schema 的更新记录。传入 `doNotPushHistory: true` 可跳过写入历史栈。 + 均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step;撤销/重做时调用方可据此按 + `propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。 ::: - **示例:** diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index a3f2165f..b0dc783a 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -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#行为扩展):** 是 diff --git a/docs/api/editor/historyServiceMethods.md b/docs/api/editor/historyServiceMethods.md index d61dae6a..e8dd55e0 100644 --- a/docs/api/editor/historyServiceMethods.md +++ b/docs/api/editor/historyServiceMethods.md @@ -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} ::: - **返回:** diff --git a/packages/editor/src/hooks/use-code-block-edit.ts b/packages/editor/src/hooks/use-code-block-edit.ts index 7fd32655..2db58764 100644 --- a/packages/editor/src/hooks/use-code-block-edit.ts +++ b/packages/editor/src/hooks/use-code-block-edit.ts @@ -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(); }; diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts index 21dc441b..d191aa42 100644 --- a/packages/editor/src/hooks/use-stage.ts +++ b/packages/editor/src/hooks/use-stage.ts @@ -115,19 +115,21 @@ export const useStage = (stageOptions: StageOptions) => { } // 多选拖动 / 多选缩放:所有元素整批走一次 update,避免历史栈被切成 N 条 + // changeRecordList 与 configs 同序,每个节点保留自己的 records; + // 不能把多个节点的 records 合并到同一个数组里,否则 doUpdate / nodeUpdateHandler 会把别的节点的 propPath 当成自己的。 const configs: MNode[] = []; - const changeRecordsAll: ReturnType = []; + const changeRecordList: ReturnType[] = []; 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) => { diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index 4327344f..d401097d 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -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, - { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}, + { changeRecords, doNotPushHistory = false }: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {}, ): Promise { - 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, 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]); diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 45269320..b19f1030 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -153,6 +153,7 @@ class DataSource extends BaseService { historyService.pushDataSource(newConfig.id, { oldSchema: oldConfig ? cloneDeep(oldConfig) : null, newSchema: newConfig, + changeRecords, }); } diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index e9b4772d..7c6c7a6b 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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 { 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 }); } diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index 52671839..373b51b2 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -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 = null,newContent = 新内容 * - 更新:oldContent / newContent 都为对应内容 * - 删除:newContent = null,oldContent = 删除前内容 + * - `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); diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 485a4174..e07c8721 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -637,8 +637,13 @@ export interface StepValue { indexMap?: Record; /** 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 diff --git a/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts b/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts index 25c66107..3b09c6ec 100644 --- a/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts +++ b/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts @@ -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 }); + }); }); diff --git a/packages/editor/tests/unit/hooks/use-stage.spec.ts b/packages/editor/tests/unit/hooks/use-stage.spec.ts index 62dcb8b0..b2b1420a 100644 --- a/packages/editor/tests/unit/hooks/use-stage.spec.ts +++ b/packages/editor/tests/unit/hooks/use-stage.spec.ts @@ -84,7 +84,9 @@ vi.mock('@editor/services/ui', () => ({ })); vi.mock('@editor/utils/editor', () => ({ - buildChangeRecords: vi.fn(() => []), + buildChangeRecords: vi.fn((style: Record, 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 事件', () => { diff --git a/packages/editor/tests/unit/services/codeBlock.spec.ts b/packages/editor/tests/unit/services/codeBlock.spec.ts index 4e0e9133..b596d7d1 100644 --- a/packages/editor/tests/unit/services/codeBlock.spec.ts +++ b/packages/editor/tests/unit/services/codeBlock.spec.ts @@ -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(); + }); }); diff --git a/packages/editor/tests/unit/services/dataSource.spec.ts b/packages/editor/tests/unit/services/dataSource.spec.ts index edac2827..5323031c 100644 --- a/packages/editor/tests/unit/services/dataSource.spec.ts +++ b/packages/editor/tests/unit/services/dataSource.spec.ts @@ -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(); + }); }); diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index ff6cd62b..0daa9615 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -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); + }); });