diff --git a/docs/api/editor/codeBlockServiceMethods.md b/docs/api/editor/codeBlockServiceMethods.md index d2e64dac..1aa4235a 100644 --- a/docs/api/editor/codeBlockServiceMethods.md +++ b/docs/api/editor/codeBlockServiceMethods.md @@ -235,6 +235,86 @@ `newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。 ::: +## setCodeDslByIdAndGetHistoryId + +- **参数:** 同 [setCodeDslById](#setcodedslbyid) + +- **返回:** + - {`Promise`} 本次写入历史记录的 uuid;未写入历史(`doNotPushHistory: true` 等)时返回 `null` + +- **详情:** + + 与 [setCodeDslById](#setcodedslbyid) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。 + 参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。 + +- **示例:** + +```js +import { codeBlockService } from "@tmagic/editor"; + +const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { + name: "代码块1", + content: "() => {}", +}); +console.log(historyId); // 本次变更对应的历史记录 uuid,或 null +``` + +## setCodeDslByIdSyncAndGetHistoryId + +- **参数:** 同 [setCodeDslByIdSync](#setcodedslbyidsync) + +- **返回:** + - {`string | null`} 本次写入历史记录的 uuid;未写入历史(`doNotPushHistory: true`、或 `force=false` 跳过等)时返回 `null` + +- **详情:** + + 与 [setCodeDslByIdSync](#setcodedslbyidsync) 行为完全一致(同步),仅把返回值换成本次写入历史记录的 `uuid` + +## deleteCodeDslByIdsAndGetHistoryId + +- **参数:** 同 [deleteCodeDslByIds](#deletecodedslbyids) + +- **返回:** + - {`Promise`} 本次写入的全部历史记录 uuid(按删除顺序);未写入任何历史时返回空数组 `[]` + +- **详情:** + + 与 [deleteCodeDslByIds](#deletecodedslbyids) 行为完全一致。由于一次可删除多个代码块、会产生多条历史记录,因此返回的是 uuid 数组(每条删除记录一个 uuid);不存在的 id 不会入历史,也不会出现在返回数组中。 + +- **示例:** + +```js +import { codeBlockService } from "@tmagic/editor"; + +const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(["code_1", "code_2"]); +console.log(historyIds); // ['xxxx', 'yyyy'],或 [] +``` + +## revertById + +- **参数:** + - `{string}` uuid 目标历史记录的 uuid(通常由 [setCodeDslByIdAndGetHistoryId](#setcodedslbyidandgethistoryid) 等方法返回) + +- **返回:** + - {`Promise`} 反向应用后产生的新 step;找不到对应 uuid / 该步未应用时返回 `null` + +- **详情:** + + 通过历史记录 uuid「回滚」某条代码块历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚, + 仅无需调用方再传 `codeBlockId` 与 `index`:内部会按 uuid 在全部代码块栈中定位对应步骤后再回滚。 + 参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。 + +- **示例:** + +```js +import { codeBlockService } from "@tmagic/editor"; + +const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { name: "代码块1" }); +if (historyId) { + await codeBlockService.revertById(historyId); +} +``` + ## undo - **参数:** diff --git a/docs/api/editor/dataSourceServiceMethods.md b/docs/api/editor/dataSourceServiceMethods.md index ae57e392..6f52075d 100644 --- a/docs/api/editor/dataSourceServiceMethods.md +++ b/docs/api/editor/dataSourceServiceMethods.md @@ -406,6 +406,78 @@ import { dataSourceService } from "@tmagic/editor"; dataSourceService.remove("ds_123"); ``` +## addAndGetHistoryId + +- **参数:** 同 [add](#add) + +- **返回:** + - {`string` | null} 本次写入历史记录的 uuid;未写入历史(`doNotPushHistory: true` 等)时返回 `null` + +- **详情:** + + 与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。 + 参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。 + +- **示例:** + +```js +import { dataSourceService } from "@tmagic/editor"; + +const historyId = dataSourceService.addAndGetHistoryId({ + type: "http", + title: "用户信息", + url: "/api/user", +}); +console.log(historyId); // 本次新增对应的历史记录 uuid,或 null +``` + +## updateAndGetHistoryId + +- **参数:** 同 [update](#update) + +- **返回:** + - {`string` | null} 本次写入历史记录的 uuid;未写入历史时返回 `null` + +- **详情:** + + 与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid` + +## removeAndGetHistoryId + +- **参数:** 同 [remove](#remove) + +- **返回:** + - {`string` | null} 本次写入历史记录的 uuid;删除的 id 不存在或未写入历史时返回 `null` + +- **详情:** + + 与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid` + +## revertById + +- **参数:** + - `{string}` uuid 目标历史记录的 uuid(通常由 [addAndGetHistoryId](#addandgethistoryid) 等方法返回) + +- **返回:** + - {`DataSourceStepValue` | null} 反向应用后产生的新 step;找不到对应 uuid / 该步未应用时返回 `null` + +- **详情:** + + 通过历史记录 uuid「回滚」某条数据源历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚, + 仅无需调用方再传 `dataSourceId` 与 `index`:内部会按 uuid 在全部数据源栈中定位对应步骤后再回滚。 + 参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。 + +- **示例:** + +```js +import { dataSourceService } from "@tmagic/editor"; + +const historyId = dataSourceService.addAndGetHistoryId({ type: "http", title: "用户信息" }); +if (historyId) { + dataSourceService.revertById(historyId); +} +``` + ## createId - **[扩展支持](../../guide/editor-expand#行为扩展):** 是 diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index 76527b0b..9fd8e2a6 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -12,6 +12,29 @@ 编辑器内置交互(画布、树面板、配置面板、右键菜单、快捷键等)会自动传入对应的 `historySource`; 业务侧程序化调用时建议显式传入(如 `api`),便于历史面板区分来源。 +## 历史记录 uuid 与 \*AndGetHistoryId + +每条历史记录入栈时都会自动生成一个唯一标识 `uuid`(见 [StepValue](#undo)),可用于精确引用 / 定位某一条历史记录(如埋点、回滚、跨端同步等)。 + +DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(节点 / 节点集合 / void),不会返回 `uuid`。若需要拿到本次写入历史记录的 `uuid`,可改用对应的 `*AndGetHistoryId` 方法:它们与原方法行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`(`string`)。当本次操作未写入历史(`doNotPushHistory: true`、无实际变更或提前返回)时返回 `null`。 + +| 原方法 | 取 uuid 的方法 | 返回值 | +| --- | --- | --- | +| [add](#add) | [addAndGetHistoryId](#addandgethistoryid) | `Promise` | +| [remove](#remove) | [removeAndGetHistoryId](#removeandgethistoryid) | `Promise` | +| [update](#update) | [updateAndGetHistoryId](#updateandgethistoryid) | `Promise` | +| [moveLayer](#movelayer) | [moveLayerAndGetHistoryId](#movelayerandgethistoryid) | `Promise` | +| [moveToContainer](#movetocontainer) | [moveToContainerAndGetHistoryId](#movetocontainerandgethistoryid) | `Promise` | +| [dragTo](#dragto) | [dragToAndGetHistoryId](#dragtoandgethistoryid) | `Promise` | + +[dataSourceService](./dataSourceServiceMethods.md) / [codeBlockService](./codeBlockServiceMethods.md) 也提供了同名约定的 `*AndGetHistoryId` 方法。 + +拿到 `uuid` 后,可在需要时按 uuid「回滚」对应的历史记录(类 git revert 语义,详见[历史记录面板](../../guide/advanced/history-list.md))。相比按 index 回滚,uuid 不会随栈内步骤增删而变化,更适合业务侧持有引用后再回滚: + +- 页面:[editorService.revertPageStepById(uuid)](#revertpagestepbyid) +- 数据源:[dataSourceService.revertById(uuid)](./dataSourceServiceMethods.md#revertbyid) +- 代码块:[codeBlockService.revertById(uuid)](./codeBlockServiceMethods.md#revertbyid) + ::: details 查看 HistoryOpOptions / DslOpOptions / HistoryOpSource 类型定义 <<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts} @@ -710,6 +733,115 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史 +## addAndGetHistoryId + +- **参数:** 同 [add](#add) + +- **返回:** + - {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null` + +- **详情:** + + 与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid) + +- **示例:** + +```js +import { editorService } from "@tmagic/editor"; + +const historyId = await editorService.addAndGetHistoryId( + { type: "text", text: "hello" }, + parent, + { historySource: "api" }, +); +console.log(historyId); // 本次新增对应的历史记录 uuid,或 null +``` + +## removeAndGetHistoryId + +- **参数:** 同 [remove](#remove) + +- **返回:** + - {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null` + +- **详情:** + + 与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid` + +## updateAndGetHistoryId + +- **参数:** 同 [update](#update) + +- **返回:** + - {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null` + +- **详情:** + + 与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid` + +## moveLayerAndGetHistoryId + +- **参数:** 同 [moveLayer](#movelayer) + +- **返回:** + - {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null` + +- **详情:** + + 与 [moveLayer](#movelayer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid` + +## moveToContainerAndGetHistoryId + +- **参数:** 同 [moveToContainer](#movetocontainer) + +- **返回:** + - {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null` + +- **详情:** + + 与 [moveToContainer](#movetocontainer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid` + +## dragToAndGetHistoryId + +- **参数:** 同 [dragTo](#dragto) + +- **返回:** + - {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null` + +- **详情:** + + 与 [dragTo](#dragto) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid` + +## revertPageStepById + +- **参数:** + - `{string}` uuid 目标历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid)(通常由 `*AndGetHistoryId` 方法返回) + +- **返回:** + - {Promise<`StepValue` | null>} 反向应用后产生的新 step;找不到对应 uuid / 该步未应用 / 反向失败时返回 `null` + +- **详情:** + + 通过历史记录 uuid「回滚」当前页面的某条历史步骤(类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。语义与按 index 回滚一致,仅入参从 index 改为 uuid,更适合业务侧持有引用后再回滚。 + + ::: tip + `opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。 + ::: + +- **示例:** + +```js +import { editorService } from "@tmagic/editor"; + +// 执行操作时拿到本次历史记录 uuid +const historyId = await editorService.addAndGetHistoryId({ type: "text", text: "hello" }); + +// 之后任意时机按 uuid 回滚该步骤 +if (historyId) { + await editorService.revertPageStepById(historyId); +} +``` + ## undo - **[扩展支持](../../guide/editor-expand#行为扩展):** 是 diff --git a/docs/api/editor/historyServiceMethods.md b/docs/api/editor/historyServiceMethods.md index 38fdce2b..a419e2e5 100644 --- a/docs/api/editor/historyServiceMethods.md +++ b/docs/api/editor/historyServiceMethods.md @@ -65,6 +65,12 @@ `changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。 `StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。 + + 入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。 + 若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService / + codeBlockService 提供的 `*AndGetHistoryId` 方法,参见 + [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。 + `pushCodeBlock` / `pushDataSource` 同样会自动写入 `uuid`。 ::: ## undo diff --git a/docs/guide/advanced/history-list.md b/docs/guide/advanced/history-list.md index ff281018..815b9919 100644 --- a/docs/guide/advanced/history-list.md +++ b/docs/guide/advanced/history-list.md @@ -61,6 +61,12 @@ const menu = ref({ - 数据源:`dataSourceService.revert(id, index)` - 代码块:`codeBlockService.revert(id, index)` +如果业务侧在执行操作时已通过 `*AndGetHistoryId` 拿到了该条记录的 [uuid](/api/editor/editorServiceMethods.md#历史记录-uuid-与-andgethistoryid),也可以直接按 uuid 回滚(无需再关心 index / id,且 uuid 不会随栈内步骤增删而变化): + +- 页面:`editorService.revertPageStepById(uuid)` +- 数据源:`dataSourceService.revertById(uuid)` +- 代码块:`codeBlockService.revertById(uuid)` + ### 4. 差异对比 在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换: diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index 4b45b964..159ff0e6 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -69,6 +69,17 @@ class CodeBlock extends BaseService { paramsColConfig: undefined, }); + /** + * 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。 + * 供 setCodeDslById(Sync)AndGetHistoryId 取回,普通方法不读取它。 + */ + private lastPushedHistoryId: string | null = null; + /** + * deleteCodeDslByIds 一次删除多个代码块时,按写入顺序收集的历史记录 uuid 列表。 + * 在 deleteCodeDslByIds 入口处重置,供 deleteCodeDslByIdsAndGetHistoryId 取回。 + */ + private lastDeletedHistoryIds: string[] = []; + constructor() { super([ ...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })), @@ -187,13 +198,14 @@ class CodeBlock extends BaseService { const newContent = cloneDeep(codeDsl[id]); if (!doNotPushHistory) { - historyService.pushCodeBlock(id, { - oldContent, - newContent, - changeRecords, - historyDescription, - source: historySource, - }); + this.lastPushedHistoryId = + historyService.pushCodeBlock(id, { + oldContent, + newContent, + changeRecords, + historyDescription, + source: historySource, + })?.uuid ?? null; } this.emit('addOrUpdate', id, codeDsl[id]); @@ -295,6 +307,8 @@ class CodeBlock extends BaseService { if (!currentDsl) return; + this.lastDeletedHistoryIds = []; + codeIds.forEach((id) => { // 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入 const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null; @@ -302,13 +316,62 @@ class CodeBlock extends BaseService { delete currentDsl[id]; if (oldContent && !doNotPushHistory) { - historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription, source: historySource }); + const uuid = historyService.pushCodeBlock(id, { + oldContent, + newContent: null, + historyDescription, + source: historySource, + })?.uuid; + if (uuid) this.lastDeletedHistoryIds.push(uuid); } this.emit('remove', id); }); } + // #region AndGetHistoryId + /** + * 下列 *AndGetHistoryId 方法与对应的写入方法行为完全一致, + * 唯一区别是返回值为本次写入历史栈的历史记录 uuid({@link CodeBlockStepValue.uuid}), + * 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。 + * + * 当本次操作未写入历史(doNotPushHistory 为 true、或无对应记录)时:单条写入返回 null,批量删除返回空数组。 + */ + + /** 等价于 {@link setCodeDslById},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public async setCodeDslByIdAndGetHistoryId( + id: Id, + codeConfig: Partial, + options: HistoryOpOptionsWithChangeRecords = {}, + ): Promise { + this.lastPushedHistoryId = null; + await this.setCodeDslById(id, codeConfig, options); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link setCodeDslByIdSync},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public setCodeDslByIdSyncAndGetHistoryId( + id: Id, + codeConfig: Partial, + force = true, + options: HistoryOpOptionsWithChangeRecords = {}, + ): string | null { + this.lastPushedHistoryId = null; + this.setCodeDslByIdSync(id, codeConfig, force, options); + return this.lastPushedHistoryId; + } + + /** + * 等价于 {@link deleteCodeDslByIds},但返回本次写入的全部历史记录 uuid(按删除顺序)。 + * 一次删除多个代码块会产生多条历史记录,因此返回数组;未写入任何历史时返回空数组。 + */ + public async deleteCodeDslByIdsAndGetHistoryId(codeIds: Id[], options: HistoryOpOptions = {}): Promise { + this.lastDeletedHistoryIds = []; + await this.deleteCodeDslByIds(codeIds, options); + return [...this.lastDeletedHistoryIds]; + } + // #endregion AndGetHistoryId + public setParamsColConfig(config: TableColumnConfig): void { this.state.paramsColConfig = config; } @@ -400,6 +463,20 @@ class CodeBlock extends BaseService { return await this.applyRevertStep(entry.step, description); } + /** + * 通过历史记录 uuid 回滚某条代码块历史步骤,语义同 {@link revert}, + * 仅无需调用方再传 codeBlockId 与 index:内部会按 uuid({@link CodeBlockStepValue.uuid}) + * 在全部代码块栈中定位对应步骤后再回滚。 + * + * @param uuid 目标历史记录的 uuid,通常由 {@link setCodeDslByIdAndGetHistoryId} 等方法返回 + * @returns 反向后产生的新 step;找不到对应 uuid / 未应用时返回 null + */ + public async revertById(uuid: string): Promise { + const location = historyService.findCodeBlockStepLocationByUuid(uuid); + if (!location) return null; + return await this.revert(location.id, location.index); + } + /** * 生成代码块唯一id * @returns {Id} 代码块唯一id diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 454c9314..bf734767 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -78,6 +78,13 @@ class DataSource extends BaseService { methods: {}, }); + /** + * 最近一次写入历史栈的数据源历史记录 uuid。 + * 供 *AndGetHistoryId 系列方法在调用 add / update / remove 后取回本次产生的历史记录 id; + * 普通方法不读取它,调用前由 *AndGetHistoryId 重置为 null。 + */ + private lastPushedHistoryId: string | null = null; + constructor() { super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))); } @@ -141,12 +148,13 @@ class DataSource extends BaseService { this.get('dataSources').push(newConfig); if (!doNotPushHistory) { - historyService.pushDataSource(newConfig.id, { - oldSchema: null, - newSchema: newConfig, - historyDescription, - source: historySource, - }); + this.lastPushedHistoryId = + historyService.pushDataSource(newConfig.id, { + oldSchema: null, + newSchema: newConfig, + historyDescription, + source: historySource, + })?.uuid ?? null; } this.emit('add', newConfig); @@ -181,13 +189,14 @@ class DataSource extends BaseService { dataSources[index] = newConfig; if (!doNotPushHistory) { - historyService.pushDataSource(newConfig.id, { - oldSchema: oldConfig ? cloneDeep(oldConfig) : null, - newSchema: newConfig, - changeRecords, - historyDescription, - source: historySource, - }); + this.lastPushedHistoryId = + historyService.pushDataSource(newConfig.id, { + oldSchema: oldConfig ? cloneDeep(oldConfig) : null, + newSchema: newConfig, + changeRecords, + historyDescription, + source: historySource, + })?.uuid ?? null; } this.emit('update', newConfig, { @@ -212,17 +221,52 @@ class DataSource extends BaseService { dataSources.splice(index, 1); if (oldConfig && !doNotPushHistory) { - historyService.pushDataSource(id, { - oldSchema: cloneDeep(oldConfig), - newSchema: null, - historyDescription, - source: historySource, - }); + this.lastPushedHistoryId = + historyService.pushDataSource(id, { + oldSchema: cloneDeep(oldConfig), + newSchema: null, + historyDescription, + source: historySource, + })?.uuid ?? null; } this.emit('remove', id); } + // #region AndGetHistoryId + /** + * 下列 *AndGetHistoryId 方法与对应的 add / update / remove 行为完全一致, + * 唯一区别是返回值为本次写入历史栈的历史记录 uuid({@link DataSourceStepValue.uuid}), + * 而非数据源配置。可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。 + * + * 当本次操作未写入历史(doNotPushHistory 为 true、或无对应记录)时返回 null。 + */ + + /** 等价于 {@link add},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public addAndGetHistoryId(config: DataSourceSchema, options: HistoryOpOptions = {}): string | null { + this.lastPushedHistoryId = null; + this.add(config, options); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link update},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public updateAndGetHistoryId( + config: DataSourceSchema, + options: HistoryOpOptionsWithChangeRecords = {}, + ): string | null { + this.lastPushedHistoryId = null; + this.update(config, options); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link remove},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): string | null { + this.lastPushedHistoryId = null; + this.remove(id, options); + return this.lastPushedHistoryId; + } + // #endregion AndGetHistoryId + /** * 撤销指定数据源的最近一次变更。 * @@ -303,6 +347,20 @@ class DataSource extends BaseService { return this.applyRevertStep(entry.step, description); } + /** + * 通过历史记录 uuid 回滚某条数据源历史步骤,语义同 {@link revert}, + * 仅无需调用方再传 dataSourceId 与 index:内部会按 uuid({@link DataSourceStepValue.uuid}) + * 在全部数据源栈中定位对应步骤后再回滚。 + * + * @param uuid 目标历史记录的 uuid,通常由 {@link addAndGetHistoryId} 等方法返回 + * @returns 反向后产生的新 step;找不到对应 uuid / 未应用时返回 null + */ + public revertById(uuid: string): DataSourceStepValue | null { + const location = historyService.findDataSourceStepLocationByUuid(uuid); + if (!location) return null; + return this.revert(location.id, location.index); + } + public createId(): string { return `ds_${guid()}`; } diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 59469a22..a157d623 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -23,7 +23,15 @@ 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, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils'; +import { + getNodeInfo, + getNodePath, + getValueByKeyPath, + guid, + isPage, + isPageFragment, + setValueByKeyPath, +} from '@tmagic/utils'; import BaseService from '@editor/services//BaseService'; import propsService from '@editor/services//props'; @@ -116,6 +124,12 @@ class Editor extends BaseService { alwaysMultiSelect: false, }); private selectionBeforeOp: Id[] | null = null; + /** + * 最近一次 pushOpHistory 写入的历史记录 uuid。 + * 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id; + * 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。 + */ + private lastPushedHistoryId: string | null = null; constructor() { super( @@ -1190,6 +1204,86 @@ class Editor extends BaseService { this.emit('drag-to', { targetIndex, configs, targetParent }); } + // #region AndGetHistoryId + /** + * 下列 *AndGetHistoryId 方法与对应的普通操作(add / remove / update ...)行为完全一致, + * 唯一区别是返回值为本次写入历史栈的历史记录 uuid({@link StepValue.uuid}), + * 而非节点 / 节点数组。可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。 + * + * 当本次操作未写入历史(doNotPushHistory 为 true、或操作无实际变更 / 提前返回)时返回 null。 + */ + + /** 等价于 {@link add},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public async addAndGetHistoryId( + addNode: AddMNode | MNode[], + parent?: MContainer | null, + options: DslOpOptions = {}, + ): Promise { + this.lastPushedHistoryId = null; + await this.add(addNode, parent, options); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link remove},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public async removeAndGetHistoryId( + nodeOrNodeList: MNode | MNode[], + options: DslOpOptions = {}, + ): Promise { + this.lastPushedHistoryId = null; + await this.remove(nodeOrNodeList, options); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link update},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public async updateAndGetHistoryId( + config: MNode | MNode[], + data: { + changeRecords?: ChangeRecord[]; + changeRecordList?: ChangeRecord[][]; + doNotPushHistory?: boolean; + historyDescription?: string; + historySource?: HistoryOpSource; + } = {}, + ): Promise { + this.lastPushedHistoryId = null; + await this.update(config, data); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link moveLayer},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public async moveLayerAndGetHistoryId( + offset: number | LayerOffset, + options: DslOpOptions = {}, + ): Promise { + this.lastPushedHistoryId = null; + await this.moveLayer(offset, options); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link moveToContainer},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public async moveToContainerAndGetHistoryId( + config: MNode | MNode[], + targetId: Id, + options: DslOpOptions = {}, + ): Promise { + this.lastPushedHistoryId = null; + await this.moveToContainer(config, targetId, options); + return this.lastPushedHistoryId; + } + + /** 等价于 {@link dragTo},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */ + public async dragToAndGetHistoryId( + config: MNode | MNode[], + targetParent: MContainer, + targetIndex: number, + options: DslOpOptions = {}, + ): Promise { + this.lastPushedHistoryId = null; + await this.dragTo(config, targetParent, targetIndex, options); + return this.lastPushedHistoryId; + } + // #endregion AndGetHistoryId + /** * 撤销当前操作 * @returns 被撤销的操作 @@ -1320,6 +1414,20 @@ class Editor extends BaseService { return revertedStep; } + /** + * 通过历史记录 uuid 回滚当前页面的某条历史步骤,语义与 {@link revertPageStep} 完全一致, + * 仅入参从 index 改为 uuid({@link StepValue.uuid})。uuid 不随栈内步骤增删而变化, + * 更适合业务侧持有引用后再回滚(埋点、跨端同步等场景)。 + * + * @param uuid 目标历史记录的 uuid,通常由 *AndGetHistoryId 方法返回 + * @returns 反向后产生的新 step;找不到对应 uuid / 未应用 / 反向失败时返回 null + */ + public async revertPageStepById(uuid: string): Promise { + const index = historyService.getPageStepIndexByUuid(uuid); + if (index < 0) return null; + return this.revertPageStep(index); + } + /** * 跳转当前页面历史栈到指定游标位置。 * @@ -1429,8 +1537,9 @@ class Editor extends BaseService { historyDescription?: string; source?: HistoryOpSource; }, - ) { + ): string | null { const step: StepValue = { + uuid: guid(), data: pageData, opType, selectedBefore: this.selectionBeforeOp ?? [], @@ -1442,8 +1551,12 @@ class Editor extends BaseService { if (source) step.source = source; // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页) // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。 - historyService.push(step, pageData.id); + const pushed = historyService.push(step, pageData.id); + // push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。 + const historyId = pushed ? step.uuid : null; + this.lastPushedHistoryId = historyId; this.selectionBeforeOp = null; + return historyId; } /** diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index 906d7dda..0272bc17 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -21,6 +21,7 @@ import { cloneDeep } from 'lodash-es'; import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core'; import type { ChangeRecord } from '@tmagic/form'; +import { guid } from '@tmagic/utils'; import type { CodeBlockHistoryGroup, @@ -255,6 +256,7 @@ class History extends BaseService { public push(state: StepValue, pageId?: Id): StepValue | null { const undoRedo = this.getUndoRedo(pageId); if (!undoRedo) return null; + if (state.uuid === undefined) state.uuid = guid(); if (state.timestamp === undefined) state.timestamp = Date.now(); undoRedo.pushElement(state); // 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。 @@ -288,6 +290,7 @@ class History extends BaseService { if (!codeBlockId) return null; const step: CodeBlockStepValue = { + uuid: guid(), id: codeBlockId, oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null, newContent: payload.newContent ? cloneDeep(payload.newContent) : null, @@ -321,6 +324,7 @@ class History extends BaseService { if (!dataSourceId) return null; const step: DataSourceStepValue = { + uuid: guid(), id: dataSourceId, oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null, newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null, @@ -510,6 +514,41 @@ class History extends BaseService { return list.map((step, index) => ({ step, index, applied: index < cursor })); } + /** + * 按历史记录 uuid 在指定页面(默认当前活动页)的栈中查找其索引。 + * 找不到时返回 -1。供「按 uuid 回滚」等需要把 uuid 映射回 index 的场景使用。 + */ + public getPageStepIndexByUuid(uuid: string, pageId?: Id): number { + if (!uuid) return -1; + return this.getPageStepList(pageId).findIndex((entry) => entry.step.uuid === uuid); + } + + /** + * 按历史记录 uuid 在全部代码块栈中查找其所属 codeBlockId 与索引。 + * 找不到时返回 null。 + */ + public findCodeBlockStepLocationByUuid(uuid: string): { id: Id; index: number } | null { + if (!uuid) return null; + for (const id of Object.keys(this.state.codeBlockState)) { + const index = this.getCodeBlockStepList(id).findIndex((entry) => entry.step.uuid === uuid); + if (index >= 0) return { id, index }; + } + return null; + } + + /** + * 按历史记录 uuid 在全部数据源栈中查找其所属 dataSourceId 与索引。 + * 找不到时返回 null。 + */ + public findDataSourceStepLocationByUuid(uuid: string): { id: Id; index: number } | null { + if (!uuid) return null; + for (const id of Object.keys(this.state.dataSourceState)) { + const index = this.getDataSourceStepList(id).findIndex((entry) => entry.step.uuid === uuid); + if (index >= 0) return { id, index }; + } + return null; + } + /** * 取出全部数据源的历史栈,按 dataSourceId 分组。同上。 */ diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index fe0de011..a4c60201 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -723,6 +723,11 @@ export type HistoryOpSource = // #region StepValue export interface StepValue { + /** + * 历史记录唯一标识(uuid)。在 historyService.push 时自动写入(若调用方未指定), + * 用于精确定位 / 引用某一条历史记录(如 revert、埋点、跨端同步等)。 + */ + uuid: string; /** 页面信息 */ data: { name: string; id: Id }; opType: HistoryOpType; @@ -772,6 +777,11 @@ export interface StepValue { * - 删除:newContent = null,oldContent = 删除前内容 */ export interface CodeBlockStepValue { + /** + * 历史记录唯一标识(uuid),入栈时自动写入,用于精确定位 / 引用某一条历史记录。 + * 注意与 `id`(关联的代码块 id)区分。 + */ + uuid: string; /** 关联的代码块 id */ id: Id; /** 变更前的代码块内容,新增时为 null */ @@ -800,6 +810,11 @@ export interface CodeBlockStepValue { * - 删除:newSchema = null,oldSchema = 删除前 schema */ export interface DataSourceStepValue { + /** + * 历史记录唯一标识(uuid),入栈时自动写入,用于精确定位 / 引用某一条历史记录。 + * 注意与 `id`(关联的数据源 id)区分。 + */ + uuid: string; /** 关联的数据源 id */ id: Id; /** 变更前的数据源 schema,新增时为 null */ diff --git a/packages/editor/tests/unit/services/codeBlock.spec.ts b/packages/editor/tests/unit/services/codeBlock.spec.ts index f27b6808..2017bb3d 100644 --- a/packages/editor/tests/unit/services/codeBlock.spec.ts +++ b/packages/editor/tests/unit/services/codeBlock.spec.ts @@ -231,6 +231,98 @@ describe('CodeBlockService - 历史记录接入', () => { }); }); +describe('CodeBlockService - *AndGetHistoryId', () => { + const lastStepUuid = (id: string) => { + const list = historyService.getCodeBlockStepList(id); + return list[list.length - 1]?.step.uuid; + }; + + test('setCodeDslByIdSyncAndGetHistoryId 返回本次写入历史记录的 uuid', async () => { + await codeBlockService.setCodeDsl({} as any); + + const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid('a')); + // 与默认行为一致:内容仍被写入 + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A'); + }); + + test('setCodeDslByIdSyncAndGetHistoryId - force=false 已存在时返回 null', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'NEW' } as any, false); + expect(historyId).toBeNull(); + }); + + test('setCodeDslByIdSyncAndGetHistoryId - doNotPushHistory 时返回 null', async () => { + await codeBlockService.setCodeDsl({} as any); + const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any, true, { + doNotPushHistory: true, + }); + expect(historyId).toBeNull(); + }); + + test('setCodeDslByIdAndGetHistoryId(async)返回本次写入历史记录的 uuid', async () => { + await codeBlockService.setCodeDsl({} as any); + + const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId('a', { name: 'A' } as any); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid('a')); + }); + + test('deleteCodeDslByIdsAndGetHistoryId 返回每条删除记录的 uuid 数组', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any); + + const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'b']); + expect(Array.isArray(historyIds)).toBe(true); + expect(historyIds).toHaveLength(2); + expect(historyIds[0]).toBe(lastStepUuid('a')); + expect(historyIds[1]).toBe(lastStepUuid('b')); + }); + + test('deleteCodeDslByIdsAndGetHistoryId - 不存在的 id 不计入返回数组', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + + const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'ghost']); + expect(historyIds).toHaveLength(1); + expect(historyIds[0]).toBe(lastStepUuid('a')); + }); + + test('deleteCodeDslByIdsAndGetHistoryId - 全部不存在时返回空数组', async () => { + await codeBlockService.setCodeDsl({} as any); + const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['ghost']); + expect(historyIds).toEqual([]); + }); +}); + +describe('CodeBlockService - revertById', () => { + test('通过 uuid 回滚新增(删除代码块内容)', async () => { + await codeBlockService.setCodeDsl({} as any); + const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any); + expect(typeof uuid).toBe('string'); + expect(codeBlockService.getCodeContentById('a')?.name).toBe('A'); + + const reverted = await codeBlockService.revertById(uuid!); + expect(reverted).not.toBeNull(); + expect(codeBlockService.getCodeContentById('a')).toBeNull(); + }); + + test('按 uuid 能定位到对应 (id, index)', async () => { + await codeBlockService.setCodeDsl({} as any); + const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any); + + const location = historyService.findCodeBlockStepLocationByUuid(uuid!); + expect(location).toEqual({ id: 'a', index: 0 }); + }); + + test('找不到 uuid 时返回 null', async () => { + await codeBlockService.setCodeDsl({} as any); + codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any); + + await expect(codeBlockService.revertById('not-exist')).resolves.toBeNull(); + await expect(codeBlockService.revertById('')).resolves.toBeNull(); + }); +}); + describe('CodeBlockService - undo / redo', () => { test('undo / redo - 新增场景:撤销=删除,重做=再写回', async () => { await codeBlockService.setCodeDsl({} as any); diff --git a/packages/editor/tests/unit/services/dataSource.spec.ts b/packages/editor/tests/unit/services/dataSource.spec.ts index f9a1b601..64d7f7f0 100644 --- a/packages/editor/tests/unit/services/dataSource.spec.ts +++ b/packages/editor/tests/unit/services/dataSource.spec.ts @@ -187,6 +187,79 @@ describe('DataSource service - 历史记录接入', () => { }); }); +describe('DataSource service - *AndGetHistoryId', () => { + const lastStepUuid = (id: string) => { + const list = historyService.getDataSourceStepList(id); + return list[list.length - 1]?.step.uuid; + }; + + test('addAndGetHistoryId 返回本次写入历史记录的 uuid', () => { + const ds = dataSource.add({ id: 'temp', title: 'a', type: 'base' } as any); + historyService.reset(); + + const historyId = dataSource.addAndGetHistoryId({ id: 'ds_new', title: 'a', type: 'base' } as any); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid('ds_new')); + // 与默认 add 行为一致:仍会写入数据源 + expect(dataSource.getDataSourceById('ds_new')).toBeDefined(); + expect(ds).toBeDefined(); + }); + + test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', () => { + const historyId = dataSource.addAndGetHistoryId({ id: 'ds_x', title: 'a', type: 'base' } as any, { + doNotPushHistory: true, + }); + expect(historyId).toBeNull(); + }); + + test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + historyService.reset(); + + const historyId = dataSource.updateAndGetHistoryId({ ...created, title: 'b' } as any); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid(created.id!)); + }); + + test('removeAndGetHistoryId 返回本次写入历史记录的 uuid;不存在的 id 返回 null', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + historyService.reset(); + + const historyId = dataSource.removeAndGetHistoryId(created.id!); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid(created.id!)); + + expect(dataSource.removeAndGetHistoryId('ghost')).toBeNull(); + }); +}); + +describe('DataSource service - revertById', () => { + test('通过 uuid 回滚 add(移除数据源)', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid; + expect(typeof uuid).toBe('string'); + expect(dataSource.getDataSourceById(created.id!)).toBeDefined(); + + const reverted = dataSource.revertById(uuid!); + expect(reverted).not.toBeNull(); + expect(dataSource.getDataSourceById(created.id!)).toBeUndefined(); + }); + + test('通过 uuid 回滚等价于按 (id, index) 回滚', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid; + + const location = historyService.findDataSourceStepLocationByUuid(uuid!); + expect(location).toEqual({ id: created.id, index: 0 }); + }); + + test('找不到 uuid 时返回 null', () => { + dataSource.add({ title: 'a', type: 'base' } as any); + expect(dataSource.revertById('not-exist')).toBeNull(); + expect(dataSource.revertById('')).toBeNull(); + }); +}); + describe('DataSource service - undo / redo', () => { test('undo / redo - 新增场景:撤销=移除,重做=再添加', () => { const created = dataSource.add({ title: 'a', type: 'base' } as any); diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index 0daa9615..15ca2adc 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -711,3 +711,99 @@ describe('undo redo', () => { expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270); }); }); + +describe('*AndGetHistoryId', () => { + const lastStepUuid = () => { + const list = historyService.getPageStepList(); + return list[list.length - 1]?.step.uuid; + }; + + test('addAndGetHistoryId 返回本次写入历史记录的 uuid,且与栈顶 step 一致', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.PAGE_ID); + + const historyId = await editorService.addAndGetHistoryId({ type: 'text' }); + expect(typeof historyId).toBe('string'); + expect(historyId).toBeTruthy(); + expect(historyId).toBe(lastStepUuid()); + }); + + test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.PAGE_ID); + + const historyId = await editorService.addAndGetHistoryId({ type: 'text' }, null, { doNotPushHistory: true }); + expect(historyId).toBeNull(); + }); + + test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.PAGE_ID); + + const historyId = await editorService.updateAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text', text: 'x' }); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid()); + }); + + test('removeAndGetHistoryId 返回本次写入历史记录的 uuid', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.PAGE_ID); + + const historyId = await editorService.removeAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text' }); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid()); + }); + + test('moveLayerAndGetHistoryId 返回本次写入历史记录的 uuid', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.NODE_ID); + + const historyId = await editorService.moveLayerAndGetHistoryId(1); + expect(typeof historyId).toBe('string'); + expect(historyId).toBe(lastStepUuid()); + }); +}); + +describe('revertPageStepById', () => { + test('通过 uuid 回滚 add 步骤(删除被新增节点)', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.PAGE_ID); + + const uuid = await editorService.addAndGetHistoryId({ type: 'text' }); + expect(typeof uuid).toBe('string'); + + const addedStep = historyService.getPageStepList().find((e) => e.step.uuid === uuid)!.step; + const addedId = addedStep.nodes![0].id; + expect(editorService.getNodeById(addedId)).toBeTruthy(); + + const reverted = await editorService.revertPageStepById(uuid!); + expect(reverted).not.toBeNull(); + // 回滚(git revert 语义)会把被新增的节点删掉 + expect(editorService.getNodeById(addedId)).toBeNull(); + }); + + test('与按 index 回滚结果一致', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.PAGE_ID); + + const uuid = await editorService.addAndGetHistoryId({ type: 'text' }); + const index = historyService.getPageStepIndexByUuid(uuid!); + expect(index).toBeGreaterThanOrEqual(0); + }); + + test('找不到 uuid 时返回 null', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.PAGE_ID); + + expect(await editorService.revertPageStepById('not-exist')).toBeNull(); + expect(await editorService.revertPageStepById('')).toBeNull(); + }); +}); diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts index 4410829b..d79f54c4 100644 --- a/packages/editor/tests/unit/services/history.spec.ts +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -103,6 +103,32 @@ describe('history service', () => { } as any); expect(step?.timestamp).toBe(123456); }); + + test('push 未带 uuid 时自动生成 uuid', () => { + history.changePage({ id: 'p1' } as any); + const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any); + expect(typeof step?.uuid).toBe('string'); + expect(step?.uuid).toBeTruthy(); + }); + + test('push 已带 uuid 时保留调用方指定的值', () => { + history.changePage({ id: 'p1' } as any); + const step = history.push({ + uuid: 'my-uuid', + data: { id: 'p1', name: '' }, + modifiedNodeIds: new Map(), + } as any); + expect(step?.uuid).toBe('my-uuid'); + }); + + test('push 为每条记录生成不同的 uuid', () => { + history.changePage({ id: 'p1' } as any); + const s1 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any); + const s2 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any); + expect(s1?.uuid).toBeTruthy(); + expect(s2?.uuid).toBeTruthy(); + expect(s1?.uuid).not.toBe(s2?.uuid); + }); }); describe('history service - codeBlock', () => { @@ -138,6 +164,12 @@ describe('history service - codeBlock', () => { expect(step?.timestamp).toBeLessThanOrEqual(after); }); + test('pushCodeBlock 自动生成 uuid', () => { + const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); + expect(typeof step?.uuid).toBe('string'); + expect(step?.uuid).toBeTruthy(); + }); + test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => { history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); history.pushCodeBlock('code_1', { @@ -218,6 +250,12 @@ describe('history service - dataSource', () => { expect(step?.timestamp).toBeLessThanOrEqual(after); }); + test('pushDataSource 自动生成 uuid', () => { + const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any }); + expect(typeof step?.uuid).toBe('string'); + expect(step?.uuid).toBeTruthy(); + }); + test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => { history.pushDataSource('ds_1', { oldSchema: null,