feat(editor): 数据源与代码块 service 支持 undo/redo

- dataSourceService / codeBlockService 新增 undo / redo / canUndo / canRedo 方法
- undo/redo 内部复用 add / update / remove / setCodeDslByIdSync / deleteCodeDslByIds 写回,
  并强制 doNotPushHistory,借此自动驱动 initService 中的依赖收集链路
  (DepTargetType.DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD / CODE_BLOCK)
- 更新场景下若 step 带 changeRecords,按 propPath 局部 patch,不冲掉同节点其它无关变更;
  缺省退化为整 schema / 整内容替换
- 补充对应单测与 API 文档
This commit is contained in:
roymondchen 2026-05-28 16:40:49 +08:00
parent 09558fa027
commit 8dae67769c
6 changed files with 606 additions and 3 deletions

View File

@ -226,6 +226,72 @@
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
:::
## undo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{Promise<CodeBlockStepValue | null>}` 撤销的 step栈不存在或已无可撤销时返回 `null`
- **详情:**
撤销指定代码块的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
复用 [setCodeDslByIdSync](#setcodedslbyidsync) / [deleteCodeDslByIds](#deletecodedslbyids) 写回,
并自动带上 `doNotPushHistory: true`,确保不会再次入栈。
写回会触发对应的 `addOrUpdate` / `remove` 事件,编辑器内部据此重新维护
`DepTargetType.CODE_BLOCK` 的 dep target无需调用方额外处理。
对于带有 `changeRecords` 的更新 step会按 `propPath` 局部 patch 当前代码块内容;缺省才退化为整内容替换,
避免冲掉同代码块上的其它无关变更。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
if (codeBlockService.canUndo("code_1234")) {
await codeBlockService.undo("code_1234");
}
```
## redo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{Promise<CodeBlockStepValue | null>}` 重做的 step栈不存在或已无可重做时返回 `null`
- **详情:**
重做指定代码块的下一次变更。其它行为同 [undo](#undo)。
## canUndo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{boolean}`
- **详情:**
当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`
## canRedo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{boolean}`
- **详情:**
当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`
## setParamsColConfig
- **参数:**

View File

@ -443,6 +443,72 @@ const ds = dataSourceService.getDataSourceById("ds_123");
console.log(ds);
```
## undo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- {`DataSourceStepValue` | null} 撤销的 step栈不存在或已无可撤销时返回 `null`
- **详情:**
撤销指定数据源的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
复用 [add](#add) / [update](#update) / [remove](#remove) 写回,并自动带上 `doNotPushHistory: true`
确保不会再次入栈。
写回会触发对应的 `add` / `update` / `remove` 事件,编辑器内部据此重新维护数据源相关的依赖收集
`DepTargetType.DATA_SOURCE` / `DATA_SOURCE_COND` / `DATA_SOURCE_METHOD`),无需调用方额外处理。
对于带有 `changeRecords` 的更新 step会按 `propPath` 局部 patch 当前数据源;缺省才退化为整 schema 替换,
避免冲掉同节点上的其它无关变更。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
if (dataSourceService.canUndo("ds_123")) {
dataSourceService.undo("ds_123");
}
```
## redo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- {`DataSourceStepValue` | null} 重做的 step栈不存在或已无可重做时返回 `null`
- **详情:**
重做指定数据源的下一次变更。其它行为同 [undo](#undo)。
## canUndo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- `{boolean}`
- **详情:**
当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`
## canRedo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- `{boolean}`
- **详情:**
当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`
## copyWithRelated
- **参数:**

View File

@ -23,11 +23,12 @@ import type { Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
import { Target, Watcher } from '@tmagic/core';
import type { ChangeRecord, TableColumnConfig } from '@tmagic/form';
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import type { AsyncHookPlugin, CodeState } from '@editor/type';
import type { AsyncHookPlugin, CodeBlockStepValue, CodeState } from '@editor/type';
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
@ -276,6 +277,46 @@ class CodeBlock extends BaseService {
return this.state.paramsColConfig;
}
/**
*
*
* setCodeDslByIdSync / deleteCodeDslByIds codeBlockService
* `addOrUpdate` / `remove` initService handler dep target
* DepTargetType.CODE_BLOCK add / remove `doNotPushHistory: true`
*
*
* @param id id
* @returns step null
*/
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.undoCodeBlock(id);
if (!step) return null;
await this.applyHistoryStep(step, true);
return step;
}
/**
*
* @param id id
* @returns step null
*/
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.redoCodeBlock(id);
if (!step) return null;
await this.applyHistoryStep(step, false);
return step;
}
/** 是否可对指定代码块撤销。 */
public canUndo(id: Id): boolean {
return historyService.canUndoCodeBlock(id);
}
/** 是否可对指定代码块重做。 */
public canRedo(id: Id): boolean {
return historyService.canRedoCodeBlock(id);
}
/**
* id
* @returns {Id} id
@ -357,6 +398,70 @@ class CodeBlock extends BaseService {
public usePlugin(options: AsyncHookPlugin<AsyncMethodName, CodeBlock>): void {
super.usePlugin(options);
}
/**
* step
*
* setCodeDslByIdSync / deleteCodeDslByIds
* initService dep target CODE_BLOCK add / remove
* `doNotPushHistory: true`
*
* - oldContent=null, newContentnull undo redo setCodeDslByIdSync
* - oldContentnull, newContent=null undo redo
* - changeRecords patch退
*
* @param step step
* @param reverse true=false=
*/
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
const { id, oldContent, newContent, changeRecords } = step;
// 新增 / 删除:直接 set 或 delete不走 patch 逻辑
if (oldContent === null && newContent) {
if (reverse) {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
} else {
this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true });
}
return;
}
if (oldContent && newContent === null) {
if (reverse) {
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true });
} else {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
}
return;
}
if (!oldContent || !newContent) return;
// 更新场景:优先按 changeRecords 局部 patch缺省退化为整内容替换
const sourceForValues = reverse ? oldContent : newContent;
if (changeRecords?.length) {
const current = this.getCodeContentById(id);
if (!current) return;
const patched = cloneDeep(current) as CodeBlockContent;
let fallbackToFullReplace = false;
for (const record of changeRecords) {
if (!record.propPath) {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patched);
}
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, true, {
changeRecords,
doNotPushHistory: true,
});
return;
}
this.setCodeDslByIdSync(id, cloneDeep(sourceForValues), true, { doNotPushHistory: true });
}
}
export type CodeBlockService = CodeBlock;

View File

@ -5,12 +5,12 @@ import type { Writable } from 'type-fest';
import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
import { Target, Watcher } from '@tmagic/core';
import type { ChangeRecord, FormConfig } from '@tmagic/form';
import { guid, toLine } from '@tmagic/utils';
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import type { DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
import type { DataSourceStepValue, DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
@ -184,6 +184,45 @@ class DataSource extends BaseService {
this.emit('remove', id);
}
/**
*
*
* add / update / remove dataSourceService
* initService handler depDATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD
* `doNotPushHistory: true`
*
* @param id id
* @returns step null
*/
public undo(id: Id) {
const step = historyService.undoDataSource(id);
if (!step) return null;
this.applyHistoryStep(step, true);
return step;
}
/**
*
* @param id id
* @returns step null
*/
public redo(id: Id) {
const step = historyService.redoDataSource(id);
if (!step) return null;
this.applyHistoryStep(step, false);
return step;
}
/** 是否可对指定数据源撤销。 */
public canUndo(id: Id): boolean {
return historyService.canUndoDataSource(id);
}
/** 是否可对指定数据源重做。 */
public canRedo(id: Id): boolean {
return historyService.canRedoDataSource(id);
}
public createId(): string {
return `ds_${guid()}`;
}
@ -258,6 +297,70 @@ class DataSource extends BaseService {
}
});
}
/**
* step
*
* add / update / remove initService
* DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD
* `doNotPushHistory: true`
*
* - oldSchema=null, newSchemanull undo redo add
* - oldSchemanull, newSchema=null undo addredo
* - changeRecords patch退 schema
*
* @param step step
* @param reverse true=false=
*/
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
const { id, oldSchema, newSchema, changeRecords } = step;
// 新增 / 删除:直接 add 或 remove不走 patch 逻辑
if (oldSchema === null && newSchema) {
if (reverse) {
this.remove(`${id}`, { doNotPushHistory: true });
} else {
this.add(cloneDeep(newSchema), { doNotPushHistory: true });
}
return;
}
if (oldSchema && newSchema === null) {
if (reverse) {
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
} else {
this.remove(`${id}`, { doNotPushHistory: true });
}
return;
}
if (!oldSchema || !newSchema) return;
// 更新场景:优先按 changeRecords 局部 patch缺省退化为整 schema 替换
const sourceForValues = reverse ? oldSchema : newSchema;
if (changeRecords?.length) {
const current = this.getDataSourceById(`${id}`);
if (!current) return;
const patched = cloneDeep(current) as DataSourceSchema;
let fallbackToFullReplace = false;
for (const record of changeRecords) {
if (!record.propPath) {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patched);
}
this.update(fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, {
changeRecords,
doNotPushHistory: true,
});
return;
}
this.update(cloneDeep(sourceForValues), { doNotPushHistory: true });
}
}
export type DataSourceService = DataSource;

View File

@ -230,3 +230,129 @@ describe('CodeBlockService - 历史记录接入', () => {
expect(step?.changeRecords).toBeUndefined();
});
});
describe('CodeBlockService - undo / redo', () => {
test('undo / redo - 新增场景:撤销=删除,重做=再写回', async () => {
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any);
await codeBlockService.undo('a');
expect(codeBlockService.getCodeContentById('a')).toBeNull();
await codeBlockService.redo('a');
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
});
test('undo / redo - 删除场景:撤销=还原内容,重做=再删除', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
historyService.reset();
await codeBlockService.deleteCodeDslByIds(['a']);
expect(codeBlockService.getCodeContentById('a')).toBeNull();
await codeBlockService.undo('a');
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
await codeBlockService.redo('a');
expect(codeBlockService.getCodeContentById('a')).toBeNull();
});
test('undo / redo - 更新场景(无 changeRecords整内容替换', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
historyService.reset();
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A2');
await codeBlockService.undo('a');
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
await codeBlockService.redo('a');
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A2');
});
test('undo / redo - 更新场景(带 changeRecords按 propPath 局部 patch不冲掉同节点其它字段', async () => {
await codeBlockService.setCodeDsl({
a: { name: 'A', desc: 'origin' },
} as any);
historyService.reset();
// form 端仅改 name
codeBlockService.setCodeDslByIdSync('a', { name: 'A2', desc: 'origin' } as any, true, {
changeRecords: [{ propPath: 'name', value: 'A2' }],
});
// 中间外部同步改了 desc不入历史
codeBlockService.setCodeDslByIdSync('a', { desc: 'changed-by-other' } as any, true, {
doNotPushHistory: true,
});
// undo 只回滚 name
await codeBlockService.undo('a');
const after = codeBlockService.getCodeContentById('a') as any;
expect(after?.name).toBe('A');
expect(after?.desc).toBe('changed-by-other');
// redo 只重做 name
await codeBlockService.redo('a');
const redo = codeBlockService.getCodeContentById('a') as any;
expect(redo?.name).toBe('A2');
expect(redo?.desc).toBe('changed-by-other');
});
test('undo / redo - 通过 addOrUpdate / remove 事件触发,依赖收集链路保留', async () => {
const addOrUpdateFn = vi.fn();
const removeFn = vi.fn();
codeBlockService.on('addOrUpdate', addOrUpdateFn);
codeBlockService.on('remove', removeFn);
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any);
addOrUpdateFn.mockClear();
// 撤销新增 → 触发 remove 事件initService 中的 codeBlockRemoveHandler 会移除 dep target
await codeBlockService.undo('a');
expect(removeFn).toHaveBeenCalledWith('a');
// 重做新增 → 触发 addOrUpdate 事件initService 会重新 addTarget
await codeBlockService.redo('a');
expect(addOrUpdateFn).toHaveBeenCalled();
codeBlockService.off('addOrUpdate', addOrUpdateFn);
codeBlockService.off('remove', removeFn);
});
test('undo / redo - 写回时不会再次入历史栈', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
historyService.reset();
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
expect(historyService.canUndoCodeBlock('a')).toBe(true);
await codeBlockService.undo('a');
expect(historyService.canRedoCodeBlock('a')).toBe(true);
await codeBlockService.redo('a');
expect(historyService.canRedoCodeBlock('a')).toBe(false);
expect(historyService.canUndoCodeBlock('a')).toBe(true);
});
test('canUndo / canRedo 委托给 historyService', async () => {
expect(codeBlockService.canUndo('ghost')).toBe(false);
expect(codeBlockService.canRedo('ghost')).toBe(false);
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any);
expect(codeBlockService.canUndo('a')).toBe(true);
expect(codeBlockService.canRedo('a')).toBe(false);
await codeBlockService.undo('a');
expect(codeBlockService.canUndo('a')).toBe(false);
expect(codeBlockService.canRedo('a')).toBe(true);
});
test('undo / redo - 栈不存在或已无可操作时返回 null', async () => {
await expect(codeBlockService.undo('ghost')).resolves.toBeNull();
await expect(codeBlockService.redo('ghost')).resolves.toBeNull();
});
});

View File

@ -186,3 +186,140 @@ describe('DataSource service - 历史记录接入', () => {
expect(step?.changeRecords).toBeUndefined();
});
});
describe('DataSource service - undo / redo', () => {
test('undo / redo - 新增场景:撤销=移除,重做=再添加', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
// undo 后数据源应被移除
const undoStep = dataSource.undo(created.id!);
expect(undoStep).not.toBeNull();
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
// redo 后数据源应被重新添加
const redoStep = dataSource.redo(created.id!);
expect(redoStep).not.toBeNull();
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
});
test('undo / redo - 删除场景:撤销=还原,重做=再删除', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.remove(created.id!);
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
dataSource.undo(created.id!);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
dataSource.redo(created.id!);
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
});
test('undo / redo - 更新场景(无 changeRecords整 schema 替换', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b');
dataSource.undo(created.id!);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
dataSource.redo(created.id!);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b');
});
test('undo / redo - 更新场景(带 changeRecords按 propPath 局部 patch不冲掉同节点其它字段', () => {
const created = dataSource.add({ title: 'a', type: 'base', description: 'origin' } as any);
historyService.reset();
// 模拟 form 端只更新 title
dataSource.update({ ...created, title: 'b', description: 'origin' } as any, {
changeRecords: [{ propPath: 'title', value: 'b' }],
});
// 在两次 update 之间用户又改了同节点的另一个字段(不入历史,模拟外部同步)
dataSource.update({ ...dataSource.getDataSourceById(created.id!), description: 'changed-by-other' } as any, {
doNotPushHistory: true,
});
// undo 应只回滚 title不影响 description
dataSource.undo(created.id!);
const after = dataSource.getDataSourceById(created.id!);
expect(after?.title).toBe('a');
expect(after?.description).toBe('changed-by-other');
// redo 应只重做 title
dataSource.redo(created.id!);
const redo = dataSource.getDataSourceById(created.id!);
expect(redo?.title).toBe('b');
expect(redo?.description).toBe('changed-by-other');
});
test('undo / redo - 通过 add / update / remove 触发事件,依赖收集链路保留', () => {
const addFn = vi.fn();
const updateFn = vi.fn();
const removeFn = vi.fn();
dataSource.on('add', addFn);
dataSource.on('update', updateFn);
dataSource.on('remove', removeFn);
const created = dataSource.add({ title: 'a', type: 'base' } as any);
addFn.mockClear();
// 撤销新增 → 触发 remove 事件initService 中的 dataSourceRemoveHandler 会清依赖
dataSource.undo(created.id!);
expect(removeFn).toHaveBeenCalledWith(created.id);
// 重做新增 → 触发 add 事件initService 会重新 collectIdle
dataSource.redo(created.id!);
expect(addFn).toHaveBeenCalled();
// 推入一次 update 历史,再 undo 触发 update 事件
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
updateFn.mockClear();
dataSource.undo(created.id!);
expect(updateFn).toHaveBeenCalled();
dataSource.off('add', addFn);
dataSource.off('update', updateFn);
dataSource.off('remove', removeFn);
});
test('undo / redo - 写回时不会再次入历史栈', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
// 此时栈里只有一条 update
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
dataSource.undo(created.id!);
// undo 后栈应可 redo并且 undo 不应再生新栈记录
expect(historyService.canRedoDataSource(created.id!)).toBe(true);
dataSource.redo(created.id!);
expect(historyService.canRedoDataSource(created.id!)).toBe(false);
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
});
test('canUndo / canRedo 委托给 historyService', () => {
expect(dataSource.canUndo('ghost')).toBe(false);
expect(dataSource.canRedo('ghost')).toBe(false);
const created = dataSource.add({ title: 'a', type: 'base' } as any);
expect(dataSource.canUndo(created.id!)).toBe(true);
expect(dataSource.canRedo(created.id!)).toBe(false);
dataSource.undo(created.id!);
expect(dataSource.canUndo(created.id!)).toBe(false);
expect(dataSource.canRedo(created.id!)).toBe(true);
});
test('undo - 栈不存在或已无可撤销时返回 null', () => {
expect(dataSource.undo('ghost')).toBeNull();
expect(dataSource.redo('ghost')).toBeNull();
});
});