refactor(editor): 统一历史栈结构,支持扩展历史类型

将 pageSteps/codeBlockState/dataSourceState 三套独立历史栈收敛为统一的 steps 结构
(按 stepType 分桶),并新增 registerStepType/setStepName/getStepName 支持自定义
扩展历史类型。同步重构 history 相关服务、组件、工具方法、测试与文档。
This commit is contained in:
roymondchen 2026-06-23 20:14:41 +08:00
parent bfdaf2b244
commit 0f42989ca3
30 changed files with 1350 additions and 1399 deletions

View File

@ -1,7 +1,7 @@
# codeBlockService方法
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock` `historyDescription` / `source` 字段。
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.push('codeBlock', step, id)` 入栈记录`historyDescription` / `source` 字段。
## setCodeDsl
@ -88,8 +88,8 @@
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
::: tip
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock`
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.push('codeBlock', step, id)`
把本次变更入历史栈,参见 [historyService.push](./historyServiceMethods.md#push)。
传入的 `changeRecords` 会一同写进 step撤销/重做时调用方可据此按 `propPath` 局部回放。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
:::
@ -231,8 +231,8 @@
在dsl数据源中删除指定id的代码块每删除一个会触发一次 `remove` 事件
::: tip
对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
对每个实际存在并被删除的代码块,会自动调用 `historyService.push('codeBlock', step, id)` 入栈一条
`newSchema=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
:::
## setCodeDslByIdAndGetHistoryId
@ -370,7 +370,7 @@ if (codeBlockService.canUndo("code_1234")) {
- **详情:**
当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`。
当前指定代码块是否可撤销,等价于 `historyService.canUndo('codeBlock', id)`。
## canRedo
@ -382,7 +382,7 @@ if (codeBlockService.canUndo("code_1234")) {
- **详情:**
当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`。
当前指定代码块是否可重做,等价于 `historyService.canRedo('codeBlock', id)`。
## setParamsColConfig

View File

@ -311,8 +311,8 @@ dataSourceService.setFormMethod("http", [
添加一个数据源如果配置中没有id或id已存在会自动生成新的id
::: tip
添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录,
参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。
添加成功会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `oldSchema=null` 的新增记录,
参见 [historyService.push](./historyServiceMethods.md#push)。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
:::
@ -355,7 +355,7 @@ console.log(newDs.id); // 自动生成的id
更新数据源
::: tip
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema`
更新成功会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `oldSchema` / `newSchema`
均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step撤销/重做时调用方可据此按
`propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。
:::
@ -394,7 +394,7 @@ console.log(updatedDs);
删除指定id的数据源
::: tip
对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null`
对实际存在的数据源会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `newSchema=null`
的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
:::
@ -579,7 +579,7 @@ if (dataSourceService.canUndo("ds_123")) {
- **详情:**
当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`。
当前指定数据源是否可撤销,等价于 `historyService.canUndo('dataSource', id)`。
## canRedo
@ -591,7 +591,7 @@ if (dataSourceService.canUndo("ds_123")) {
- **详情:**
当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`。
当前指定数据源是否可重做,等价于 `historyService.canRedo('dataSource', id)`。
## copyWithRelated

View File

@ -862,6 +862,10 @@ await editorService.revertPageStepById(historyIds);
::: details 查看 StepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#StepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
@ -883,6 +887,10 @@ await editorService.revertPageStepById(historyIds);
::: details 查看 StepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#StepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}

View File

@ -1,24 +1,18 @@
# historyService事件
## page-change
- **详情:** 页面切换
- **事件回调函数:** `(undoRedo: UndoRedo) => void`
::: details 查看 UndoRedo 类定义
<<< @/../packages/editor/src/utils/undo-redo.ts#UndoRedo{ts}
:::
## change
- **详情:** 历史记录发生变化
- **详情:** 页面历史记录发生变化(`page` 类型 `push` / `undo` / `redo` 成功时触发;与 `code-block-history-change` / `data-source-history-change` 同构)
- **事件回调函数:** `(state: StepValue | null) => void`
- **事件回调函数:** `(pageId: Id, step: StepValue) => void`
::: details 查看 StepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#StepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
@ -29,18 +23,36 @@
:::
:::tip
当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state``null`
回调签名已与其它历史类型统一为 `(id, step)`。当游标处于历史栈边界(已无法继续撤销 / 重做)时 `undo` / `redo` 返回 `null`,此时不会触发该事件。
:::
## marker-change
- **详情:** 通过 [`setMarker`](./historyServiceMethods.md#setmarker) 为某个历史栈种入 `initial` 基线时触发(适用于所有类型)
- **事件回调函数:** `(id: Id, marker: StepValue, stepType: HistoryStepType) => void`
## clear
- **详情:** 调用 [`clear`](./historyServiceMethods.md#clear) 清空历史栈时触发(适用于所有类型)
- **事件回调函数:** `(id: Id | undefined, stepType: HistoryStepType) => void`
:::tip
`id` 缺省(清空 `stepType` 下全部栈)时回调的 `id``undefined`
:::
## code-block-history-change
- **详情:** 代码块历史记录发生变化(`pushCodeBlock` / `undoCodeBlock` / `redoCodeBlock` 成功时触发)
- **详情:** 代码块历史记录发生变化(`push('codeBlock', step, codeBlockId)` / `undo('codeBlock', id)` / `redo('codeBlock', id)` 成功时触发)
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
@ -49,40 +61,42 @@
:::
:::tip
- 新增触发的 step `oldContent` 为 `null`
- 删除触发的 step `newContent` 为 `null`
- 新增触发的 step 其 diff 项 `oldSchema` 为 `null`
- 删除触发的 step 其 diff 项 `newSchema` 为 `null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
:::
## data-source-history-change
- **详情:** 数据源历史记录发生变化(`pushDataSource` / `undoDataSource` / `redoDataSource` 成功时触发)
- **详情:** 数据源历史记录发生变化(`push('dataSource', step, dataSourceId)` / `undo('dataSource', id)` / `redo('dataSource', id)` 成功时触发)
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
:::tip
- 新增触发的 step `oldSchema``null`
- 删除触发的 step `newSchema``null`
- 新增触发的 step 其 diff 项 `oldSchema``null`
- 删除触发的 step 其 diff 项 `newSchema``null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
:::
## mark-saved
- **详情:** 调用 `markSaved` / `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved` 标记「已保存」记录时触发
- **详情:** 调用 [`markSaved`](./historyServiceMethods.md#marksaved) 标记「已保存」记录时触发
- **事件回调函数:** `(payload: { kind: 'all' | 'page' | 'code-block' | 'data-source'; id?: Id }) => void`
- **事件回调函数:** `(payload: { kind: 'all' | HistoryStepType; id?: Id }) => void`
::: tip
- `markSaved` 触发时 `kind``all`,无 `id`
- 细粒度方法触发时 `kind` 对应类别,`id` 为目标页面 / 代码块 / 数据源 id
- `markSaved(stepType)`(缺省 id触发时 `kind``all`,无 `id`(此时 `stepType` 不生效)
- `markSaved(stepType, id)` 触发时 `kind` 为对应的 `stepType``page` / `codeBlock` / `dataSource` / 扩展),`id` 为目标栈 id
:::
## save-to-indexed-db

View File

@ -4,43 +4,71 @@
- **详情:**
重置全部历史记录(包括页面节点栈、代码块栈、数据源栈),并重置当前页面 id / canRedo / canUndo
## resetPage
- **详情:**
重置当前页面的历史记录状态清空当前页面id重置 canRedo/canUndo
重置全部历史记录:清空 `state.steps` 下的页面 / 代码块 / 数据源 / 扩展类型全部栈(保留已注册的扩展类型键)。
## resetState
- **详情:**
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo、codeBlockState、dataSourceState
同 [`reset`](#reset),清空 `state.steps` 下全部栈。
## changePage
::: tip
历史服务不再维护「当前活动页」状态(已移除 `state.pageId` / `state.canUndo` / `state.canRedo`)。
活动页由 `editorService` 维护,撤销 / 重做 / 读取页面历史时请显式传入 pageId。
是否可撤销 / 重做请改用 [`canUndo`](#canundo) / [`canRedo`](#canredo)。
:::
## registerStepType
- **参数:**
- `{MPage | MPageFragment} page`
::: details 查看 MPage / MPageFragment 类型定义
<<< @/../packages/schema/src/index.ts#MPage{ts}
<<< @/../packages/schema/src/index.ts#MPageFragment{ts}
:::
- `{string} stepType` 自定义历史类型标识(勿与内置 `page` / `codeBlock` / `dataSource` 重名)
- `{Object} options` 可选
- `{string} event` push / undo / redo 后派发的事件名;缺省为 `${stepType}-history-change`
- `{string} name` 历史面板中的展示名称tab / 分组标题等);缺省回退到 stepType 本身
- **详情:**
按页面切换历史堆栈
注册一个扩展历史类型,使其可与内置 `page` / `codeBlock` / `dataSource` 一样走统一的
[`push`](#push) / [`undo`](#undo) / [`redo`](#redo)(按 id 分栈、独立 undo/redo
注册后该类型的栈存放在 `historyService.state.steps[stepType]`,展示名称存放在 `historyService.state.stepNames[stepType]`
## getStepName
- **参数:**
- `{HistoryStepType} stepType` 历史类型
- **返回:**
- `{string}` 该类型的展示名称(用于历史面板 tab / 分组标题等);未登记时回退到 stepType 本身
- **详情:**
读取指定历史类型的展示名称。内置 `page` / `codeBlock` / `dataSource` 默认分别为「页面 / 代码块 / 数据源」。
## setStepName
- **参数:**
- `{HistoryStepType} stepType` 历史类型
- `{string} name` 展示名称
- **详情:**
设置指定历史类型的展示名称(写入 `historyService.state.stepNames`,历史面板会响应式刷新)。
内置 `page` / `codeBlock` / `dataSource` 也可在此覆盖默认中文名。
## push
- **参数:**
- `{StepValue} state`
- `{HistoryStepType} stepType` 历史类型,内置 `'page'` / `'codeBlock'` / `'dataSource'`,并支持通过 [`registerStepType`](#registersteptype) 扩展
- `{StepValue | BaseStepValue} step` 已构造好的历史记录(缺省自动补全 `uuid` / `timestamp`
- `{Id} id` 必填;目标栈 id`page` 为 pageId其余类型为对应资源 id
::: details 查看 StepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#StepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
@ -53,278 +81,137 @@
:::
- **返回:**
- `{StepValue | null}`
- `{StepValue | BaseStepValue | null}` 入栈失败(未传 / 无效 id时返回 `null`
- **详情:**
添加一条历史记录
添加一条历史记录。统一入口,所有类型(`page` / `codeBlock` / `dataSource` / 扩展)行为完全一致:按 `stepType` 选择目标栈类型、按 `id`(必填)选择具体栈,按需建栈后入栈,并派发对应的历史变更事件(`page``change`,其余如 `code-block-history-change` / `data-source-history-change`),回调签名统一为 `(id, step)`
跨页 / 跨资源操作(如把节点搬到其它页)必须显式传入目标 id。`codeBlock` / `dataSource` 的 step 通常由 `createStackStep` 等工具按 `oldValue` / `newValue` 构造后传入。
::: tip
`opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按
`opType: 'update'` 的每个 diff 项上可携带 `changeRecords`,用于撤销 / 重做时仅按
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
`StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
`step` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。
若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService /
codeBlockService 提供的 `*AndGetHistoryId` 方法,参见
[editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
`pushCodeBlock` / `pushDataSource` 同样会自动写入 `uuid`
:::
## undo
- **参数:**
- `{HistoryStepType} stepType` 历史类型
- `{Id} id` 必填;目标栈 id`page` 为 pageId其余类型为对应资源 id
- **返回:**
- `{StepValue | null}`
- `{StepValue | BaseStepValue | null}`
- **详情:**
撤销当前操作。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``oldNode` 取值做局部回滚;否则用 `oldNode` 整节点替换。
撤销指定历史栈的最近一次变更。所有类型行为一致:按 `stepType` + `id` 定位栈,不会越过 index 0 的 initial 基线(所有类型同等适用,见 [`setMarker`](#setmarker)),仅在确有可撤销 step 时派发对应的历史变更事件(`page``change`,回调签名 `(id, step)`)。
`page` 类型 `opType: 'update'` 时,若 diff 项的 `changeRecords` 存在,会按 `propPath``oldSchema` 取值做局部回滚;否则用 `oldSchema` 整节点替换。`codeBlock` / `dataSource` 拿到 step 后由调用方写回对应 service本方法不会自动回放
## redo
- **参数:**
- `{HistoryStepType} stepType` 历史类型
- `{Id} id` 必填;目标栈 id`page` 为 pageId其余类型为对应资源 id
- **返回:**
- `{StepValue | null}`
- `{StepValue | BaseStepValue | null}`
- **详情:**
恢复到下一步。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``newNode` 取值做局部重做;否则用 `newNode` 整节点替换。
恢复指定历史栈到下一步,语义与 [`undo`](#undo) 对称。`page` 类型 `opType: 'update'` 时,若 diff 项的 `changeRecords` 存在,会按 `propPath``newSchema` 取值做局部重做;否则用 `newSchema` 整节点替换。
## pushCodeBlock
## canUndo
- **参数:**
- `{Id} codeBlockId` 代码块 id
- `{Object} payload`
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
- `{CodeBlockStepValue | null}` 入栈失败(未传 id时返回 `null`
- **详情:**
推入一条代码块变更记录。与页面 / 节点完全无关,按 `codeBlockId` 维度独立一份 `UndoRedo` 栈,
栈实例存放在 `historyService.state.codeBlockState[codeBlockId]`
入栈成功后会触发 `code-block-history-change` 事件。
::: tip
`codeBlockService.setCodeDslByIdSync``codeBlockService.deleteCodeDslByIds` 内部已经
自动调用本方法,业务代码通常无需手动调用。
:::
## undoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可撤销记录时返回 `null`
- **详情:**
撤销指定代码块的最近一次变更。成功时会触发 `code-block-history-change` 事件。
拿到 step 后由调用方根据 `step.oldContent` 写回 `codeBlockService`(本方法不会自动回放)。
## redoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可重做记录时返回 `null`
- **详情:**
重做指定代码块的下一次变更。成功时会触发 `code-block-history-change` 事件。
## canUndoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- `{HistoryStepType} stepType` 历史类型
- `{Id} id` 可选;目标栈 id缺省 / 无效时返回 `false`
- **返回:**
- `{boolean}`
- **详情:**
指定代码块当前是否可撤销。栈不存在时返回 `false`
指定历史栈当前是否可撤销(游标高于 index 0 的 initial 基线底线)。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
## canRedoCodeBlock
## canRedo
- **参数:**
- `{Id} codeBlockId`
- `{HistoryStepType} stepType` 历史类型
- `{Id} id` 可选;目标栈 id缺省 / 无效时返回 `false`
- **返回:**
- `{boolean}`
- **详情:**
指定代码块当前是否可重做。栈不存在时返回 `false`
指定历史栈当前是否可重做。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
## pushDataSource
## setMarker
- **参数:**
- `{Id} dataSourceId` 数据源 id
- `{Object} payload`
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null`
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- `{HistoryStepType} stepType` 历史类型
- `{Id} id` 目标栈 id`page` 为 pageId其余类型为对应资源 id
- `{Object} options` 可选:`name` / `description` / `source`,用于基线的展示信息
- **返回:**
- `{DataSourceStepValue | null}` 入栈失败(未传 id时返回 `null`
- `{StepValue | null}` 已存在基线时返回原基线;栈非空(无基线)或 id 无效时返回 `null`
- **详情:**
推入一条数据源变更记录。与页面 / 节点完全无关,按 `dataSourceId` 维度独立一份 `UndoRedo` 栈,
栈实例存放在 `historyService.state.dataSourceState[dataSourceId]`
为指定历史栈种入一条 `opType: 'initial'` 的「初始基线」记录,作为该栈 index 0 的固定底线:它是真实入栈并随栈持久化的 step但被钉为撤销 / 回滚的下限,`undo` / `goto` / `revert` 都不会越过它。所有类型(含扩展类型)均可设置基线,仅当目标栈为空时种入。
入栈成功后会触发 `data-source-history-change` 事件。
::: tip
`dataSourceService.add` / `update` / `remove` 内部已经自动调用本方法,业务代码通常无需手动调用。
:::
## undoDataSource
## getMarker
- **参数:**
- `{Id} dataSourceId`
- `{HistoryStepType} stepType` 历史类型
- `{Id} id` 可选;目标栈 id缺省 / 无效时返回 `undefined`
- **返回:**
- `{DataSourceStepValue | null}`
- `{StepValue | undefined}`
- **详情:**
撤销指定数据源的最近一次变更。成功时会触发 `data-source-history-change` 事件。
拿到 step 后由调用方根据 `step.oldSchema` 写回 `dataSourceService`(本方法不会自动回放)。
## redoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{DataSourceStepValue | null}`
- **详情:**
重做指定数据源的下一次变更。成功时会触发 `data-source-history-change` 事件。
## canUndoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可撤销。栈不存在时返回 `false`
## canRedoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可重做。栈不存在时返回 `false`
读取指定历史栈的初始基线 step栈 index 0 且 `opType: 'initial'`),不存在时返回 `undefined`
## markSaved
- **参数:**
- `{HistoryStepType} stepType` 历史类型,内置另有 `'codeBlock'` / `'dataSource'`,并支持扩展(仅在传入 `id` 时生效)
- `{Id} id` 可选;目标栈 id。缺省表示标记全部类型、全部栈
- **详情:**
标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标记为已保存(`saved = true`)。
标记历史记录为「已保存」(把对应栈当前游标所在的记录标记为 `saved = true`)。统一入口:
同一栈内任意时刻最多保留一条已保存记录标记前会清除该栈内全部旧标记某个栈处于「全部已撤销」cursor 为 0时不会留下已保存记录从 IndexedDB 恢复时其游标会回到 0。
- **缺省 `id`**:标记「整份 DSL 已保存」——把所有类型、所有栈当前游标所在的记录都标记为已保存(此时 `stepType` 不生效),触发 `mark-saved` 事件且 `{ kind: 'all' }`。通常在 DSL 整体落库成功后调用。
- **传入 `id`**:仅标记 `stepType` 下该 id 对应的栈,触发 `mark-saved` 事件且 `{ kind: stepType, id }`(如 `{ kind: 'page', id }` / `{ kind: 'codeBlock', id }` / `{ kind: 'dataSource', id }`)。
通常在 DSL 整体落库(保存到后端 / 本地)成功后调用,配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。仅保存了其中一类时请改用更细粒度的 `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved`
同一栈内任意时刻最多保留一条已保存记录标记前会清除该栈内全部旧标记某个栈处于「全部已撤销」cursor 为 0时不会留下已保存记录从 IndexedDB 恢复时其游标会回到 0。配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。
调用后会触发 `mark-saved` 事件(`{ kind: 'all' }`)。
## markPageSaved
## clear
- **参数:**
- `{Id} pageId` 可选;缺省为当前活动页
- `{HistoryStepType} stepType` 历史类型,内置另有 `'codeBlock'` / `'dataSource'`,并支持扩展
- `{Id} id` 可选;目标栈 id。缺省表示清空 `stepType` 下的全部栈
- **详情:**
标记指定页面(缺省当前活动页)历史栈的当前记录为已保存,仅影响该页面自己的栈。触发 `mark-saved` 事件(`{ kind: 'page', id }`)。
清空历史记录栈。统一入口所有类型page / codeBlock / dataSource / 扩展)行为一致:
## markCodeBlockSaved
- **传入 `id`**:仅清空 `stepType` 下该 id 对应的栈;
- **缺省 `id`**:清空 `stepType` 下的全部栈。
- **参数:**
- `{Id} codeBlockId`
- **详情:**
标记指定代码块历史栈的当前记录为已保存,仅影响该代码块自己的栈。触发 `mark-saved` 事件(`{ kind: 'code-block', id }`)。
## markDataSourceSaved
- **参数:**
- `{Id} dataSourceId`
- **详情:**
标记指定数据源历史栈的当前记录为已保存,仅影响该数据源自己的栈。触发 `mark-saved` 事件(`{ kind: 'data-source', id }`)。
## clearPage
- **参数:**
- `{Id} pageId` 可选;缺省为当前活动页
- **详情:**
清空指定页面(缺省当前活动页)的历史记录栈。仅删除撤销/重做记录,不会改动当前 DSL清空后该页将无法再撤销/重做之前的操作。清空当前活动页时会同步刷新 `canUndo` / `canRedo` 并触发 `change` 事件。
## clearCodeBlock
- **参数:**
- `{Id} codeBlockId` 可选;缺省清空全部代码块
- **详情:**
清空代码块历史记录栈:传入 `codeBlockId` 仅清空该代码块,缺省清空全部代码块。仅删除撤销/重做记录,不会改动代码块本身。
## clearDataSource
- **参数:**
- `{Id} dataSourceId` 可选;缺省清空全部数据源
- **详情:**
清空数据源历史记录栈:传入 `dataSourceId` 仅清空该数据源,缺省清空全部数据源。仅删除撤销/重做记录,不会改动数据源本身。
仅删除撤销/重做记录,不会改动 DSL / 代码块 / 数据源本身。清空时会**保留各栈原有的 initial 基线**(文案 / 来源,见 [`setMarker`](#setmarker)),无基线时清空成空栈。清空后触发 `clear` 事件(签名 `(id, stepType)`)。
## saveToIndexedDB
@ -344,7 +231,7 @@
- **详情:**
把当前内存中的全部历史栈(页面 / 代码块 / 数据源)连同各自游标、容量序列化后写入本地 IndexedDB。
把当前内存中的全部历史栈(页面 / 代码块 / 数据源 / 扩展类型)连同各自游标、容量序列化后写入本地 IndexedDB。
- 最终库名为 `${dbName}-${当前 DSL app id}`,按应用隔离;
- `key` 用于在同一 store 下区分不同记录,缺省为 `default`
@ -371,14 +258,32 @@
- 每个栈都会按 `listMaxSize` 裁剪并还原游标;
- 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐;
- 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 `pageId`
- 会整体覆盖当前内存中的历史状态(活动页由 `editorService` 维护,不在此恢复)
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
成功后触发 `restore-from-indexed-db``change` 事件。
成功后触发 `restore-from-indexed-db` 事件。
## findStepLocationByUuid
- **参数:**
- `{HistoryStepType} stepType` 历史类型
- `{string} uuid` 目标历史记录的 uuid
- `{Id} id` 可选;目标栈 id
- **返回:**
- `{ { id: Id; index: number } | null }` 找到时返回所属栈 id 与步骤索引;找不到时返回 `null`
- **详情:**
按历史记录 uuid 在指定历史类型的栈中查找其所属 id 与索引,统一入口。
- **传入 `id`**:仅在该 id 对应的单个栈中查找(如页面历史按活动页查看,传入 pageId
- **缺省 `id`**:遍历该类型下全部栈查找(代码块 / 数据源等按全部资源分桶的场景)。
供「按 uuid 回滚」等需要把 uuid 映射回 `(id, index)` 的场景使用,如 [editorService.revertPageStepById](./editorServiceMethods.md#revertpagestepbyid) / [codeBlockService.revertById](./codeBlockServiceMethods.md#revertbyid) / [dataSourceService.revertById](./dataSourceServiceMethods.md#revertbyid) 内部均通过本方法定位步骤。
## destroy
- **详情:**
销毁

View File

@ -189,7 +189,11 @@ const defaultLoadConfig = async (): Promise<FormConfig> => {
);
}
case 'data-source': {
return dataSourceService.getFormConfig(props.type || 'base');
const config = dataSourceService.getFormConfig(props.type || 'base');
// tab status 'fields'tab-pane name 'fields'
// active Tabs '0' 'fields'
// DataSourceConfigPanel tab
return config.map((item) => ('type' in item && item.type === 'tab' ? { ...item, active: 'fields' } : item));
}
case 'code-block': {
return getCodeBlockFormConfig({

View File

@ -90,7 +90,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
className: 'undo',
icon: markRaw(Back),
tooltip: `后退(${ctrl}+z)`,
disabled: () => !historyService.state.canUndo,
disabled: () => !historyService.canUndo('page', editorService.get('page')?.id),
handler: () => editorService.undo(),
});
break;
@ -100,7 +100,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
className: 'redo',
icon: markRaw(Right),
tooltip: `前进(${ctrl}+Shift+z)`,
disabled: () => !historyService.state.canRedo,
disabled: () => !historyService.canRedo('page', editorService.get('page')?.id),
handler: () => editorService.redo(),
});
break;

View File

@ -18,7 +18,7 @@
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
<component
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}"
v-bind="tabPaneComponent?.props({ name: 'page', label: `${pageName} (${pageGroups.length})` }) || {}"
>
<PageTab
:list="pageGroupsDisplay"
@ -37,7 +37,10 @@
<component
v-if="!disabledDataSource"
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
v-bind="
tabPaneComponent?.props({ name: 'data-source', label: `${dataSourceName} (${dataSourceGroups.length})` }) ||
{}
"
>
<BucketTab
:config="dataSourceConfig"
@ -55,7 +58,9 @@
<component
v-if="!disabledCodeBlock"
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
v-bind="
tabPaneComponent?.props({ name: 'code-block', label: `${codeBlockName} (${codeBlockGroups.length})` }) || {}
"
>
<BucketTab
:config="codeBlockConfig"
@ -204,7 +209,15 @@ const {
* 当前活动页的加载/初始标记记录设置 root 时生成透传给 PageTab 的底部初始行展示
* 基于 historyService reactive state 派生活动页切换或标记写入后自动刷新
*/
const pageMarker = computed(() => historyService.getPageMarker());
const pageMarker = computed(() => historyService.getMarker('page', editorService.get('page')?.id));
/**
* 各历史类型的展示名称页面 / 数据源 / 代码块 historyService.state.stepNames 配置
* 业务方可通过 historyService.setStepName / registerStepType 覆盖基于 reactive state 派生配置变更后自动刷新
*/
const pageName = computed(() => historyService.getStepName('page'));
const dataSourceName = computed(() => historyService.getStepName('dataSource'));
const codeBlockName = computed(() => historyService.getStepName('codeBlock'));
/** 代码块 step 仅 update前后 content 都存在)时可查看差异。 */
const isStepDiffable = (step: BaseStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
@ -212,23 +225,26 @@ const isStepDiffable = (step: BaseStepValue) => Boolean(step.diff?.[0]?.oldSchem
/**
* 数据源 / 代码块两类 bucket 历史的整体渲染配置 title / prefix 与各自的描述
* 可差异可回滚判定收敛为单一对象整体注入 BucketTab组件内部按需读取
* title / 描述回退名取自可配置的展示名称故用 computed 使其随 stepNames 变更刷新
*/
// / describeGroup toRowGroup 退 describeStep
const dataSourceConfig: HistoryBucketConfig<DataSourceStepValue> = {
title: '数据源',
const dataSourceConfig = computed<HistoryBucketConfig<DataSourceStepValue>>(() => ({
title: dataSourceName.value,
prefix: 'ds',
describeStep: (step: DataSourceStepValue): string => describeStep(step, (schema) => schema?.title, '数据源'),
describeStep: (step: DataSourceStepValue): string =>
describeStep(step, (schema) => schema?.title, dataSourceName.value),
isStepDiffable,
isStepRevertable: isSingleDiffStepRevertable,
};
}));
const codeBlockConfig: HistoryBucketConfig<CodeBlockStepValue> = {
title: '代码块',
const codeBlockConfig = computed<HistoryBucketConfig<CodeBlockStepValue>>(() => ({
title: codeBlockName.value,
prefix: 'cb',
describeStep: (step: CodeBlockStepValue): string => describeStep(step, (content) => content?.name, '代码块'),
describeStep: (step: CodeBlockStepValue): string =>
describeStep(step, (content) => content?.name, codeBlockName.value),
isStepDiffable,
isStepRevertable: isSingleDiffStepRevertable,
};
}));
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
const indexToCursor = (index: number) => index + 1;
@ -244,7 +260,7 @@ const onPageGoto = (index: number) => {
* - step 涉及的节点都已不存在如删除记录被撤销的新增时给出提示不做选中
*/
const onPageSelect = async (index: number) => {
const step = historyService.getPageStepList()[index]?.step;
const step = historyService.getStepList('page', editorService.get('page')?.id)[index]?.step;
if (!step) return;
const targetId = (step.diff ?? [])
.map((item) => item.newSchema?.id ?? item.oldSchema?.id)
@ -312,7 +328,7 @@ const onPageClear = async () => {
'确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
)
) {
historyService.clearPage();
historyService.clear('page', editorService.get('page')?.id);
await syncIndexedDB();
}
};
@ -323,7 +339,7 @@ const onDataSourceClear = async () => {
'确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
)
) {
historyService.clearDataSource();
historyService.clear('dataSource');
await syncIndexedDB();
}
};
@ -334,7 +350,7 @@ const onCodeBlockClear = async () => {
'确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
)
) {
historyService.clearCodeBlock();
historyService.clear('codeBlock');
await syncIndexedDB();
}
};

View File

@ -34,7 +34,7 @@ import { computed } from 'vue';
import { TMagicScrollbar } from '@tmagic/design';
import type { HistoryRowDescriptor, PageHistoryGroup, StepValue } from '@editor/type';
import type { HistoryGroup, HistoryRowDescriptor, StepValue } from '@editor/type';
import type { HistoryRowGroup } from './composables';
import {
@ -53,7 +53,7 @@ defineOptions({
const props = defineProps<{
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
list: PageHistoryGroup[];
list: HistoryGroup<StepValue>[];
/**
* 共享的折叠状态表key -> 是否展开缺省或 true 为展开false 为收起由顶层 panel 统一维护
* tab 使用 `pg-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
@ -105,9 +105,9 @@ const descriptor: HistoryRowDescriptor<StepValue> = {
isStepRevertable: isPageStepRevertable,
};
const rowKey = (group: PageHistoryGroup) => `pg-${group.steps[0]?.index}`;
const rowKey = (group: HistoryGroup<StepValue>) => `pg-${group.steps[0]?.index}`;
const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
const toRow = (group: HistoryGroup<StepValue>): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
/**
* 是否处于"初始状态"即对应页面历史栈 cursor===0

View File

@ -3,10 +3,10 @@ import { datetimeFormatter } from '@tmagic/form';
import type {
BaseStepValue,
HistoryGroup,
HistoryOpSource,
HistoryOpType,
HistoryRowDescriptor,
PageHistoryGroup,
StepValue,
} from '@editor/type';
@ -146,7 +146,7 @@ export const groupSource = (group: { steps: { step: { source?: HistoryOpSource }
export const groupOperator = (group: { steps: { step: { operator?: string } }[] }): string | undefined =>
group.steps[group.steps.length - 1]?.step.operator;
/** {@link toRowGroup} 接受的最小分组结构PageHistoryGroup 与 HistoryBucketGroup 均满足。 */
/** {@link toRowGroup} 接受的最小分组结构 */
interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
applied: boolean;
isCurrent?: boolean;
@ -259,7 +259,7 @@ export const describePageStep = (step: StepValue): string => describeStep(step,
* - describePageStep
* - + propPath
*/
export const describePageGroup = (group: PageHistoryGroup) => {
export const describePageGroup = (group: HistoryGroup<StepValue>) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describePageStep(group.steps[0].step);

View File

@ -11,7 +11,7 @@ import { useServices } from '@editor/hooks/use-services';
* historyService reactive state
*/
export const useHistoryList = () => {
const { historyService } = useServices();
const { editorService, historyService } = useServices();
/**
* key `pg-${ index}` / `ds-${id}-${ index}` / `cb-${id}-${ index}`
@ -23,9 +23,9 @@ export const useHistoryList = () => {
expanded[key] = expanded[key] === false;
};
const pageGroups = computed(() => historyService.getPageHistoryGroups());
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups());
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups());
const pageGroups = computed(() => historyService.getHistoryGroups('page', editorService.get('page')?.id));
const dataSourceGroups = computed(() => historyService.getHistoryGroups('dataSource'));
const codeBlockGroups = computed(() => historyService.getHistoryGroups('codeBlock'));
/** 页面 tab 倒序展示(最新一组在最上面)。 */
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());

View File

@ -227,7 +227,7 @@ export const useHistoryRevert = (
buildDiffPayload(
{
category: 'node',
groups: () => historyService.getPageHistoryGroups(),
groups: () => historyService.getHistoryGroups('page', editorService.get('page')?.id),
getCurrent: (id) => editorService.getNodeById(id) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || '',
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
@ -239,7 +239,7 @@ export const useHistoryRevert = (
buildDiffPayload(
{
category: 'data-source',
groups: () => historyService.getDataSourceHistoryGroups(),
groups: () => historyService.getHistoryGroups('dataSource'),
getCurrent: (id) => dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || 'base',
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
@ -252,7 +252,7 @@ export const useHistoryRevert = (
buildDiffPayload(
{
category: 'code-block',
groups: () => historyService.getCodeBlockHistoryGroups(),
groups: () => historyService.getHistoryGroups('codeBlock'),
getCurrent: (id) => codeBlockService.getCodeContentById(id) as Record<string, any> | null,
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
},
@ -267,7 +267,7 @@ export const useHistoryRevert = (
* add使
*/
const isPageRevertTargetMissing = (index: number): boolean => {
const step = historyService.getPageStepList()[index]?.step;
const step = historyService.getStepList('page', editorService.get('page')?.id)[index]?.step;
if (!step) return false;
if (step.opType === 'update') {
return (step.diff ?? []).some((item) => {
@ -285,13 +285,13 @@ export const useHistoryRevert = (
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getDataSourceStepList(id)[index]?.step;
const step = historyService.getStepList('dataSource', id)[index]?.step;
return Boolean(step?.opType === 'update' && !dataSourceService.getDataSourceById(`${id}`));
};
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getCodeBlockStepList(id)[index]?.step;
const step = historyService.getStepList('codeBlock', id)[index]?.step;
return Boolean(step?.opType === 'update' && !codeBlockService.getCodeContentById(id));
};

View File

@ -39,7 +39,7 @@ import type {
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import BaseService from './BaseService';
@ -188,14 +188,14 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushCodeBlock(id, {
oldContent,
newContent,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(id, {
oldValue: oldContent,
newValue: newContent,
changeRecords,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? historyService.push('codeBlock', step, id) : null)?.uuid ?? null;
}
this.emit('addOrUpdate', id, codeDsl[id]);
@ -301,17 +301,18 @@ class CodeBlock extends BaseService {
codeIds.forEach((id) => {
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
const oldValue: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
delete currentDsl[id];
if (oldContent && !doNotPushHistory) {
const uuid = historyService.pushCodeBlock(id, {
oldContent,
newContent: null,
if (oldValue && !doNotPushHistory) {
const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(id, {
oldValue,
newValue: null,
historyDescription,
source: historySource,
})?.uuid;
});
const uuid = step ? historyService.push('codeBlock', step, id)?.uuid : undefined;
if (uuid) this.lastDeletedHistoryIds.push(uuid);
}
@ -385,7 +386,7 @@ class CodeBlock extends BaseService {
* @returns step null
*/
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.undoCodeBlock(id);
const step = historyService.undo('codeBlock', id);
if (!step) return null;
await this.applyHistoryStep(step, true);
return step;
@ -397,7 +398,7 @@ class CodeBlock extends BaseService {
* @returns step null
*/
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.redoCodeBlock(id);
const step = historyService.redo('codeBlock', id);
if (!step) return null;
await this.applyHistoryStep(step, false);
return step;
@ -405,12 +406,12 @@ class CodeBlock extends BaseService {
/** 是否可对指定代码块撤销。 */
public canUndo(id: Id): boolean {
return historyService.canUndoCodeBlock(id);
return historyService.canUndo('codeBlock', id);
}
/** 是否可对指定代码块重做。 */
public canRedo(id: Id): boolean {
return historyService.canRedoCodeBlock(id);
return historyService.canRedo('codeBlock', id);
}
/**
@ -421,7 +422,7 @@ class CodeBlock extends BaseService {
* @returns
*/
public async goto(id: Id, targetCursor: number): Promise<number> {
let cursor = historyService.getCodeBlockCursor(id);
let cursor = historyService.getCursor('codeBlock', id);
const target = Math.max(0, targetCursor);
while (cursor > target) {
const step = await this.undo(id);
@ -447,14 +448,14 @@ class CodeBlock extends BaseService {
* @returns step / null
*/
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
const list = historyService.getCodeBlockStepList(id);
const list = historyService.getStepList('codeBlock', id);
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.id, entry.step.diff?.[0], (s) => s.name)}`;
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.data.id, entry.step.diff?.[0], (s) => s.name)}`;
return await this.applyRevertStep(entry.step, description);
}
@ -468,7 +469,7 @@ class CodeBlock extends BaseService {
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
const results: (CodeBlockStepValue | null)[] = [];
for (const uuid of uuids) {
const location = historyService.findCodeBlockStepLocationByUuid(uuid);
const location = historyService.findStepLocationByUuid('codeBlock', uuid);
results.push(location ? await this.revert(location.id, location.index) : null);
}
return results;
@ -564,19 +565,19 @@ class CodeBlock extends BaseService {
step: CodeBlockStepValue,
historyDescription: string,
): Promise<CodeBlockStepValue | null> {
const { id } = step;
const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (!oldSchema && newSchema) {
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即写回
if (oldSchema && !newSchema) {
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
if (!oldSchema || !newSchema) return null;
@ -600,11 +601,11 @@ class CodeBlock extends BaseService {
historyDescription,
historySource: 'rollback',
});
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
/**
@ -622,7 +623,7 @@ class CodeBlock extends BaseService {
* @param reverse true=false=
*/
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
const { id } = step;
const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 set 或 delete不走 patch 逻辑

View File

@ -20,7 +20,7 @@ import type {
} from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import BaseService from './BaseService';
@ -137,13 +137,13 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig);
if (!doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: null,
newSchema: newConfig,
historyDescription,
source: historySource,
})?.uuid ?? null;
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
oldValue: null,
newValue: newConfig,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
}
this.emit('add', newConfig);
@ -178,14 +178,14 @@ class DataSource extends BaseService {
dataSources[index] = newConfig;
if (!doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
oldValue: oldConfig ? cloneDeep(oldConfig) : null,
newValue: newConfig,
changeRecords,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
}
this.emit('update', newConfig, {
@ -210,13 +210,13 @@ class DataSource extends BaseService {
dataSources.splice(index, 1);
if (oldConfig && !doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushDataSource(id, {
oldSchema: cloneDeep(oldConfig),
newSchema: null,
historyDescription,
source: historySource,
})?.uuid ?? null;
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(id, {
oldValue: cloneDeep(oldConfig),
newValue: null,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null;
}
this.emit('remove', id);
@ -270,7 +270,7 @@ class DataSource extends BaseService {
* @returns step null
*/
public undo(id: Id) {
const step = historyService.undoDataSource(id);
const step = historyService.undo('dataSource', id);
if (!step) return null;
this.applyHistoryStep(step, true);
return step;
@ -282,7 +282,7 @@ class DataSource extends BaseService {
* @returns step null
*/
public redo(id: Id) {
const step = historyService.redoDataSource(id);
const step = historyService.redo('dataSource', id);
if (!step) return null;
this.applyHistoryStep(step, false);
return step;
@ -290,12 +290,12 @@ class DataSource extends BaseService {
/** 是否可对指定数据源撤销。 */
public canUndo(id: Id): boolean {
return historyService.canUndoDataSource(id);
return historyService.canUndo('dataSource', id);
}
/** 是否可对指定数据源重做。 */
public canRedo(id: Id): boolean {
return historyService.canRedoDataSource(id);
return historyService.canRedo('dataSource', id);
}
/**
@ -306,7 +306,7 @@ class DataSource extends BaseService {
* @returns
*/
public goto(id: Id, targetCursor: number): number {
let cursor = historyService.getDataSourceCursor(id);
let cursor = historyService.getCursor('dataSource', id);
const target = Math.max(0, targetCursor);
while (cursor > target) {
if (!this.undo(id)) break;
@ -330,13 +330,13 @@ class DataSource extends BaseService {
* @returns step / null
*/
public revert(id: Id, index: number): DataSourceStepValue | null {
const list = historyService.getDataSourceStepList(id);
const list = historyService.getStepList('dataSource', id);
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.id, entry.step.diff?.[0], (s) => s.title)}`;
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.data.id, entry.step.diff?.[0], (s) => s.title)}`;
return this.applyRevertStep(entry.step, description);
}
@ -349,7 +349,7 @@ class DataSource extends BaseService {
*/
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
return uuids.map((uuid) => {
const location = historyService.findDataSourceStepLocationByUuid(uuid);
const location = historyService.findStepLocationByUuid('dataSource', uuid);
if (!location) return null;
return this.revert(location.id, location.index);
});
@ -435,19 +435,19 @@ class DataSource extends BaseService {
* add / update / remove doNotPushHistory
*/
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
const { id } = step;
const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (!oldSchema && newSchema) {
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即重新加回
if (oldSchema && !newSchema) {
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
if (!oldSchema || !newSchema) return null;
@ -471,11 +471,11 @@ class DataSource extends BaseService {
historyDescription,
historySource: 'rollback',
});
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
/**
@ -493,7 +493,7 @@ class DataSource extends BaseService {
* @param reverse true=false=
*/
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
const { id } = step;
const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 add 或 remove不走 patch 逻辑

View File

@ -165,8 +165,8 @@ class Editor extends BaseService {
// 重复创建set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
app.items?.forEach((pageNode) => {
if (pageNode?.id !== undefined && !historyService.getPageMarker(pageNode.id)) {
historyService.setPageMarker(pageNode.id, {
if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
historyService.setMarker('page', pageNode.id, {
name: pageNode.name,
source: options.historySource,
});
@ -297,12 +297,6 @@ class Editor extends BaseService {
this.set('page', page);
this.set('parent', parent);
if (page) {
historyService.changePage(toRaw(page));
} else {
historyService.resetState();
}
if (node?.id) {
this.get('stage')
?.renderer?.runtime?.getApp?.()
@ -586,8 +580,6 @@ class Editor extends BaseService {
stage?.select(pages[0].id);
} else {
this.selectRoot();
historyService.resetPage();
}
};
@ -1320,7 +1312,9 @@ class Editor extends BaseService {
* @returns
*/
public async undo(): Promise<StepValue | null> {
const value = historyService.undo();
const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = historyService.undo('page', pageId);
if (value) {
await this.applyHistoryOp(value, true);
}
@ -1332,7 +1326,9 @@ class Editor extends BaseService {
* @returns
*/
public async redo(): Promise<StepValue | null> {
const value = historyService.redo();
const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = historyService.redo('page', pageId);
if (value) {
await this.applyHistoryOp(value, false);
}
@ -1356,7 +1352,7 @@ class Editor extends BaseService {
* @returns step / / null
*/
public async revertPageStep(index: number): Promise<StepValue | null> {
const list = historyService.getPageStepList();
const list = historyService.getStepList('page', this.get('page')?.id);
const entry = list[index];
if (!entry?.applied) return null;
@ -1374,7 +1370,8 @@ class Editor extends BaseService {
// 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
let revertedStep: StepValue | null = null;
const captureRevert = (s: StepValue) => {
// page 的 `change` 事件回调签名为 `(pageId, step)`,这里只关心被回滚产生的新 step。
const captureRevert = (_pageId: Id, s: StepValue) => {
revertedStep = s;
};
historyService.once('change', captureRevert);
@ -1461,9 +1458,10 @@ class Editor extends BaseService {
*/
public async revertPageStepById(uuids: string[]): Promise<(StepValue | null)[]> {
const results: (StepValue | null)[] = [];
const pageId = this.get('page')?.id;
for (const uuid of uuids) {
const index = historyService.getPageStepIndexByUuid(uuid);
results.push(index < 0 ? null : await this.revertPageStep(index));
const location = historyService.findStepLocationByUuid('page', uuid, pageId);
results.push(!location ? null : await this.revertPageStep(location.index));
}
return results;
}
@ -1478,8 +1476,9 @@ class Editor extends BaseService {
* @returns
*/
public async gotoPageStep(targetCursor: number): Promise<number> {
let cursor = historyService.getPageCursor();
const { length } = historyService.getPageStepList();
const pageId = this.get('page')?.id;
let cursor = historyService.getCursor('page', pageId);
const { length } = historyService.getStepList('page', pageId);
const target = Math.max(0, Math.min(targetCursor, length));
while (cursor > target) {
const step = await this.undo();
@ -1628,9 +1627,11 @@ class Editor extends BaseService {
uuid: guid(),
data: { name: page.name || '', id: page.id },
opType,
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
extra: {
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
},
diff: [diffItem],
rootStep: true,
};
@ -1638,9 +1639,9 @@ class Editor extends BaseService {
const top = historyService.getCurrentPageStep(page.id);
if (top?.rootStep && top.source === source) {
historyService.replaceCurrentPageStep(step, page.id);
historyService.replaceCurrentStep('page', step, page.id);
} else {
historyService.push(step, page.id);
historyService.push('page', step, page.id);
}
}
@ -1662,16 +1663,18 @@ class Editor extends BaseService {
uuid: guid(),
data: pageData,
opType,
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
extra: {
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
},
diff,
};
if (historyDescription) step.historyDescription = historyDescription;
if (source) step.source = source;
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
const pushed = historyService.push(step, pageData.id);
const pushed = historyService.push('page', step, pageData.id);
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
const historyId = pushed ? step.uuid : null;
this.lastPushedHistoryId = historyId;
@ -1799,11 +1802,11 @@ class Editor extends BaseService {
}
}
this.set('modifiedNodeIds', step.modifiedNodeIds);
this.set('modifiedNodeIds', step.extra?.modifiedNodeIds ?? new Map());
const page = toRaw(this.get('page'));
if (page) {
const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
setTimeout(() => {
if (!selectIds.length) return;

File diff suppressed because it is too large Load Diff

View File

@ -780,13 +780,18 @@ export interface StepDiffItem<T = unknown> {
*
* `T` `diff` `MNode` / `CodeBlockContent` / `DataSourceSchema`
*/
export interface BaseStepValue<T = unknown> {
export interface BaseStepValue<T = unknown, U extends Record<string, any> = {}> {
/**
* uuid
* / revert
* `id` / / id
* `data.id` / / id
*/
uuid: string;
/**
* `id` / / id key
* `name`
*/
data: { name: string; id: Id };
/** 操作类型:新增 / 删除 / 更新(三类历史记录统一携带)。 */
opType: HistoryOpType;
/**
@ -823,65 +828,93 @@ export interface BaseStepValue<T = unknown> {
/** 操作人 */
operator?: string;
/** 扩展信息 */
extra?: Record<string, any>;
extra?: U;
}
// #endregion BaseStepValue
// #region StepValue
export interface StepValue extends BaseStepValue<MNode> {
/** 页面信息 */
data: { name: string; id: Id };
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */
selectedBefore: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[];
modifiedNodeIds: Map<Id, Id>;
// #region StepExtra
/**
* {@link BaseStepValue.extra}
* `page` /
*/
export interface StepExtra {
/** 操作前选中的节点 ID用于撤销后恢复选择状态page 类型) */
selectedBefore?: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态page 类型) */
selectedAfter?: Id[];
/** 本次操作涉及的节点 id 集合page 类型) */
modifiedNodeIds?: Map<Id, Id>;
[key: string]: any;
}
// #endregion StepExtra
// #region StepValue
/**
* `diff` {@link MNode} /
* {@link BaseStepValue} id `data.id` `extra`
*/
export type StepValue = BaseStepValue<MNode, StepExtra>;
// #endregion StepValue
// #region CodeBlockStepValue
/**
* codeBlock.id historyState.codeBlockState
* `diff` {@link StepDiffItem}
* - opType 'add' `newSchema`
* - opType 'update'`oldSchema` + `newSchema` `changeRecords`
* - opType 'remove' `oldSchema`
* `diff` {@link CodeBlockContent} `data.id`codeBlock.id
* historyState.steps.codeBlock {@link StepValue} / {@link DataSourceStepValue}
* `diff`
*/
export interface CodeBlockStepValue extends BaseStepValue<CodeBlockContent> {
/** 关联的代码块 id */
id: Id;
}
export type CodeBlockStepValue = BaseStepValue<CodeBlockContent>;
// #endregion CodeBlockStepValue
// #region DataSourceStepValue
/**
* dataSource.id historyState.dataSourceState
* `diff` {@link StepDiffItem}
* - opType 'add' `newSchema` schema
* - opType 'update'`oldSchema` + `newSchema` `changeRecords`
* - opType 'remove' `oldSchema` schema
* `diff` {@link DataSourceSchema} `data.id`dataSource.id
* historyState.steps.dataSource {@link StepValue} / {@link CodeBlockStepValue}
* `diff`
*/
export interface DataSourceStepValue extends BaseStepValue<DataSourceSchema> {
/** 关联的数据源 id */
id: Id;
}
export type DataSourceStepValue = BaseStepValue<DataSourceSchema>;
// #endregion DataSourceStepValue
// #region HistorySteps
/**
* `page` / `codeBlock` / `dataSource`
* `(string & {})`
*/
export type HistoryStepType = 'page' | 'codeBlock' | 'dataSource' | (string & {});
/**
* -> id -> UndoRedo
*
* - `page` page.id UndoRedo
* - `codeBlock` codeBlock.id
* - `dataSource` dataSource.id
* - {@link HistoryService.registerStepType}
*
* id undo/redo
* {@link HistoryService.setMarker} index 0 `initial` 线 / 线
*/
export interface HistorySteps {
page: Record<Id, UndoRedo<StepValue>>;
codeBlock: Record<Id, UndoRedo<CodeBlockStepValue>>;
dataSource: Record<Id, UndoRedo<DataSourceStepValue>>;
/** 扩展历史类型:按 id 分组的 UndoRedo 栈。 */
[stepType: string]: Record<Id, UndoRedo<any>>;
}
// #endregion HistorySteps
export interface HistoryState {
pageId?: Id;
pageSteps: Record<Id, UndoRedo<StepValue>>;
canRedo: boolean;
canUndo: boolean;
/**
* codeBlock.id UndoRedo
* / undo/redo
* / / / {@link HistorySteps}
* id undo/redo / {@link HistoryService.canUndo} /
* {@link HistoryService.canRedo} stepType + id canUndo / canRedo
*/
codeBlockState: Record<Id, UndoRedo<CodeBlockStepValue>>;
steps: HistorySteps;
/**
* dataSource.id UndoRedo
* / undo/redo
* {@link HistorySteps} tab /
* `page` / `codeBlock` / `dataSource` / /
* {@link HistoryService.registerStepType} `name`
* {@link HistoryService.setStepName} {@link HistoryService.getStepName}
*/
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
stepNames: Record<string, string>;
}
// #region PersistedHistoryState
@ -892,14 +925,16 @@ export interface HistoryState {
export interface PersistedHistoryState {
/** 快照结构版本号,便于后续兼容升级。 */
version: number;
/** 保存时的活动页 id。 */
pageId?: Id;
/** 各页面历史栈的序列化快照,按 pageId 分组。 */
pageSteps: Record<Id, SerializedUndoRedo<StepValue>>;
/** 各代码块历史栈的序列化快照,按 codeBlockId 分组。 */
codeBlockState: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
/** 各数据源历史栈的序列化快照,按 dataSourceId 分组。 */
dataSourceState: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
/**
* -> id {@link HistorySteps}
* `page` / `codeBlock` / `dataSource`
*/
steps: {
page: Record<Id, SerializedUndoRedo<StepValue>>;
codeBlock: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
dataSource: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
[stepType: string]: Record<Id, SerializedUndoRedo<any>>;
};
/** 保存时间戳(毫秒)。 */
savedAt: number;
}
@ -927,9 +962,9 @@ export interface HistoryPersistOptions {
/**
*
*/
export interface PageHistoryStepEntry {
export interface HistoryStepEntry<T> {
/** 步骤内容 */
step: StepValue;
step: T;
/** 在所属栈中的索引0 为最早) */
index: number;
/** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */
@ -939,55 +974,37 @@ export interface PageHistoryStepEntry {
}
/**
*
* - updatedItems[0].oldNode.id 'update'
* - / add / remove
* - targetId undefined "无明确目标" add/remove/ update
*/
export interface PageHistoryGroup {
kind: 'page';
/** 所属页面 id */
pageId: Id;
/** 该分组的操作类型 */
opType: HistoryOpType;
/**
* id"单节点 update" id id update
* undefined add / remove / update
*/
targetId?: Id;
/** 目标节点的可读名(取最后一步的 newNode.name/type/id */
targetName?: string;
/** 组内所有步骤,按时间正序 */
steps: PageHistoryStepEntry[];
/** 组内最后一步是否已应用 */
applied: boolean;
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
/**
* / id
* `kind` step
* - `StackHistoryGroup<DataSourceStepValue, 'data-source'>`
* - `StackHistoryGroup<CodeBlockStepValue, 'code-block'>`
* / / /
*
* {@link PageHistoryGroup} `steps`
* "目标"
* - updatetargetId
* - add / remove / update
* - targetId undefined "无明确目标"
*
* `kind` step
* - `HistoryGroup<StepValue>``kind: 'page'``id` pageId`targetId` id
* - `HistoryGroup<DataSourceStepValue>``kind: 'data-source'``id` dataSource.id
* - `HistoryGroup<CodeBlockStepValue>``kind: 'code-block'``id` codeBlock.id
*/
export interface StackHistoryGroup<
T extends BaseStepValue = BaseStepValue,
K extends 'code-block' | 'data-source' = 'code-block' | 'data-source',
> {
/** 区分代码块 / 数据源。 */
kind: K;
/** 关联的代码块 / 数据源 id。 */
export interface HistoryGroup<T extends BaseStepValue = BaseStepValue> {
/** 历史类型标识page / code-block / data-source扩展类型同理。 */
kind: string;
/** 所属栈 idpage 为 pageId代码块 / 数据源为对应资源 id。 */
id: Id;
/** 该分组的操作类型。 */
opType: HistoryOpType;
/** 组内所有步骤,按时间正序(不做相邻合并,恒为单元素)。 */
/**
* id"单实体 update" id update
* undefined add / remove / update
*/
targetId?: Id;
/** 目标可读名(取最后一步快照的 name/type/id。 */
targetName?: string;
/** 组内所有步骤,按时间正序。 */
steps: { step: T; index: number; applied: boolean; isCurrent?: boolean }[];
/** 组内最后一步是否已应用,用于整组的状态展示。 */
/** 组内最后一步是否已应用。 */
applied: boolean;
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
// #endregion HistoryListEntry
@ -1203,6 +1220,21 @@ export interface EditorEvents {
'history-change': [data: MPage | MPageFragment];
}
export interface HistoryEvents {
change: [
state: BaseStepValue | StepValue | CodeBlockStepValue | DataSourceStepValue,
stepType: HistoryStepType,
id: Id,
];
'code-block-history-change': [id: Id, state: CodeBlockStepValue];
'data-source-history-change': [id: Id, state: DataSourceStepValue];
'restore-from-indexed-db': [snapshot: PersistedHistoryState | null];
'save-to-indexed-db': [snapshot: PersistedHistoryState];
'mark-saved': [{ kind: HistoryStepType; id?: Id }];
clear: [{ id: Id; stepType: HistoryStepType }];
'marker-change': [{ id: Id; marker: StepValue; stepType: HistoryStepType }];
}
export const canUsePluginMethods = {
async: [
'getLayout',

View File

@ -23,15 +23,7 @@ import type { Id } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type {
BaseStepValue,
HistoryOpSource,
PageHistoryGroup,
PageHistoryStepEntry,
StackHistoryGroup,
StepDiffItem,
StepValue,
} from '@editor/type';
import type { BaseStepValue, HistoryGroup, StepDiffItem } from '@editor/type';
import { UndoRedo } from './undo-redo';
@ -71,17 +63,20 @@ export const detectStackOpType = (oldVal: unknown, newVal: unknown): 'add' | 're
*
* - `add`oldValue = null`remove`newValue = null`update` changeRecords
* - cloneDeep opType old/new null
* - step emit pushCodeBlock / pushDataSource
* - step emit history.push(stepType, step, id)
* - service
*/
export const createStackStep = <T, S extends BaseStepValue<T> & { id: Id }>(
export const createStackStep = <T, S extends BaseStepValue<T>>(
id: Id,
payload: {
// payload 以 {@link BaseStepValue} 为基础透传字段historyDescription / source / operator / rootStep / extra
// 随 BaseStepValue 演进自动同步,原样写入 step自动生成字段uuid / data / opType / diff / timestamp / saved
// 从 payload 中排除由本方法内部构造。oldValue / newValue / changeRecords / name 为构造 diff 与 data 用的输入。
payload: Omit<BaseStepValue<T>, 'uuid' | 'data' | 'opType' | 'diff' | 'timestamp' | 'saved'> & {
oldValue: T | null;
newValue: T | null;
changeRecords?: ChangeRecord[];
historyDescription?: string;
source?: HistoryOpSource;
/** 展示名(缺省时从快照 name / title 推断)。 */
name?: string;
},
): S | null => {
if (!id) return null;
@ -90,10 +85,13 @@ export const createStackStep = <T, S extends BaseStepValue<T> & { id: Id }>(
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
const opType = detectStackOpType(payload.oldValue, payload.newValue);
// 展示名:代码块取 name数据源取 title取不到则留空不影响 undo/redo仅用于展示
const schema = (payload.newValue ?? payload.oldValue) as { name?: string; title?: string } | null;
const name = payload.name ?? schema?.name ?? schema?.title ?? '';
const step: BaseStepValue<T> & { id: Id } = {
const step: BaseStepValue<T> = {
uuid: guid(),
id,
data: { name, id },
opType,
diff: [
{
@ -104,7 +102,10 @@ export const createStackStep = <T, S extends BaseStepValue<T> & { id: Id }>(
],
historyDescription: payload.historyDescription,
source: payload.source,
operator: payload.operator,
rootStep: payload.rootStep,
timestamp: Date.now(),
extra: payload.extra,
};
return step as S;
@ -121,61 +122,41 @@ export const markStackSaved = <S extends { saved?: boolean }>(undoRedo?: UndoRed
};
/**
* id / group
* update
* / / / "目标"
* - 'update' targetId targetId update group
* - 'add' / 'remove'
* - 'update'
*
* `kind` `kind`
* `kind` step
*/
export const mergeStackSteps = <S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
kind: K,
export const mergeSteps = <T extends BaseStepValue>(
kind: string,
id: Id,
list: S[],
list: T[],
cursor: number,
): StackHistoryGroup<S, K>[] => {
const currentIndex = cursor - 1;
return list.map((step, index) => {
const applied = index < cursor;
const isCurrent = index === currentIndex;
return {
kind,
id,
opType: step.opType,
steps: [{ step, index, applied, isCurrent }],
applied,
isCurrent,
};
});
};
/**
* group
* - 'update' targetId targetId update group
* - 'add' / 'remove'
* - 'update'
*/
export const mergePageSteps = (pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] => {
const groups: PageHistoryGroup[] = [];
let current: PageHistoryGroup | null = null;
): HistoryGroup<T>[] => {
const groups: HistoryGroup<T>[] = [];
let current: HistoryGroup<T> | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const applied = index < cursor;
const isCurrent = index === currentIndex;
const targetId = detectPageTargetId(step);
const targetName = detectPageTargetName(step);
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
const targetId = detectTargetId(step);
const targetName = detectTargetName(step);
const entry = { step, index, applied, isCurrent };
// 仅"单节点 update"参与合并其它情形add/remove/多节点 update始终独立成组。
// 仅"单实体 update"参与合并其它情形add/remove/多实体 update始终独立成组。
const mergeable = step.opType === 'update' && targetId !== undefined;
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
current.steps.push(entry);
current.applied = applied;
if (isCurrent) current.isCurrent = true;
// 保持目标名为最近一次的(节点重命名时也能反映)
// 保持目标名为最近一次的(重命名时也能反映)
if (targetName) current.targetName = targetName;
} else {
current = {
kind: 'page',
pageId,
kind,
id,
opType: step.opType,
targetId: mergeable ? targetId : undefined,
targetName,
@ -190,37 +171,40 @@ export const mergePageSteps = (pageId: Id, list: StepValue[], cursor: number): P
};
/**
* StepValue "目标节点 id"
* - update updatedItems id
* - update / add / remove undefined
* step "目标 id"
* - update diff id id CodeBlockContent退 `step.data.id`
* id使 / update
* - update / add / remove undefined
*/
export const detectPageTargetId = (step: StepValue): Id | undefined => {
export const detectTargetId = (step: BaseStepValue): Id | undefined => {
if (step.opType !== 'update') return undefined;
const items = step.diff;
if (items?.length !== 1) return undefined;
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
const newSchema = items[0].newSchema as { id?: Id } | undefined;
const oldSchema = items[0].oldSchema as { id?: Id } | undefined;
return newSchema?.id ?? oldSchema?.id ?? step.data?.id;
};
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
export const detectPageTargetName = (step: StepValue): string | undefined => {
/** 解析 step 中的目标可读名(用于 UI 展示)。 */
export const detectTargetName = (step: BaseStepValue): string | undefined => {
const items = step.diff;
if (step.opType === 'update') {
if (items?.length === 1) {
const node = items[0].newSchema || items[0].oldSchema;
const node = (items[0].newSchema || items[0].oldSchema) as { name?: string; type?: string; id?: Id } | undefined;
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'add') {
if (items?.length === 1) {
const n = items[0].newSchema;
const n = items[0].newSchema as { name?: string; type?: string; id?: Id } | undefined;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'remove') {
if (items?.length === 1) {
const n = items[0].oldSchema;
const n = items[0].oldSchema as { name?: string; type?: string; id?: Id } | undefined;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
@ -290,10 +274,11 @@ export const getOrCreateStack = <T>(stacks: Record<Id, UndoRedo<T>>, id: Id): Un
};
/**
* index 0 `opType: 'initial'` 线 step 1线 0
* cursor 线 undo / canUndo / goto 线
* index 0 `opType: 'initial'` 线 step 1线 0
* page / codeBlock / dataSource / cursor 线
* undo / canUndo / goto 线
*/
export const undoFloor = (undoRedo: UndoRedo<StepValue>): number => {
export const undoFloor = (undoRedo: UndoRedo<any>): number => {
return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
};

View File

@ -15,7 +15,7 @@ const editorService = {
undo: vi.fn(),
redo: vi.fn(),
};
const historyService = { state: { canUndo: true, canRedo: true } };
const historyService = { state: { steps: {} }, canUndo: vi.fn(() => true), canRedo: vi.fn(() => true) };
const uiService = {
get: vi.fn(),
set: vi.fn(),

View File

@ -8,6 +8,27 @@ import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import historyService from '@editor/services/history';
import { createStackStep } from '@editor/utils/history';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
const pushCodeBlock = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
});
return step ? historyService.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
});
return step ? historyService.push('dataSource', step as any, id) : null;
};
// 页面 push 现在必须显式传 pageId默认推到 'p1'(与 editorService.get('page') 的 mock 对齐)。
const pushPage = (step: any, id: any = 'p1') => historyService.push('page', step, id);
const { onPageDiff, onDataSourceDiff, onCodeBlockDiff, onPageRevert, onDataSourceRevert, onCodeBlockRevert } =
vi.hoisted(() => ({
@ -26,7 +47,8 @@ const editorService = {
revertPageStep: vi.fn(async () => null),
getNodeById: vi.fn((id: string | number) => ({ id })),
select: vi.fn(async () => {}),
get: vi.fn(() => ({ select: stageSelect })),
// 历史面板已改为显式按当前页 id 取页面历史,'page' 返回固定 id 'p1'。
get: vi.fn((key?: string) => (key === 'page' ? { id: 'p1' } : { select: stageSelect })),
};
const stageOverlayService = { get: vi.fn(() => ({ select: overlayStageSelect })) };
const dataSourceService = {
@ -139,17 +161,16 @@ describe('HistoryListPanel.vue', () => {
});
test('页面/数据源/代码块 数据齐全时各 tab 渲染对应内容', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.pushDataSource('ds_1', {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any,
});
historyService.pushCodeBlock('code_1', {
pushCodeBlock('code_1', {
oldContent: null,
newContent: { id: 'code_1', name: 'CB' } as any,
});
@ -168,7 +189,6 @@ describe('HistoryListPanel.vue', () => {
});
test('点击合并组头部能切换 expanded 状态(不触发 goto', async () => {
historyService.changePage({ id: 'p1' } as any);
// 推两个修改同一节点的步骤,会合并为一个 group
const mkUpdate = (path: string) => ({
opType: 'update',
@ -181,8 +201,8 @@ describe('HistoryListPanel.vue', () => {
},
],
});
historyService.push(mkUpdate('a') as any);
historyService.push(mkUpdate('b') as any);
pushPage(mkUpdate('a') as any);
pushPage(mkUpdate('b') as any);
const wrapper = await factory();
await nextTick();
@ -203,13 +223,12 @@ describe('HistoryListPanel.vue', () => {
});
test('点击页面 group 头部调用 editorService.gotoPageStep', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.push({
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(),
@ -234,8 +253,7 @@ describe('HistoryListPanel.vue', () => {
});
test('点击页面 group 头部选中对应节点editorService.select + 画布 select 联动)', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
@ -257,8 +275,7 @@ describe('HistoryListPanel.vue', () => {
});
test('点击页面记录时节点已不存在则提示且不选中', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'remove',
diff: [{ oldSchema: { id: 'gone', name: 'G' } }],
modifiedNodeIds: new Map(),
@ -278,7 +295,7 @@ describe('HistoryListPanel.vue', () => {
});
test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => {
historyService.pushDataSource('ds_1', {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any,
});
@ -287,7 +304,7 @@ describe('HistoryListPanel.vue', () => {
await nextTick();
// 当前 ds 组isCurrent点击不触发 goto为了能触发先撤销该步使其变为非当前
historyService.undoDataSource('ds_1');
historyService.undo('dataSource', 'ds_1');
await nextTick();
const heads = wrapper.findAll('.m-editor-history-list-group-head');
@ -299,7 +316,7 @@ describe('HistoryListPanel.vue', () => {
});
test('点击代码块组头部调用 codeBlockService.goto(id, cursor)', async () => {
historyService.pushCodeBlock('code_1', {
pushCodeBlock('code_1', {
oldContent: null,
newContent: { id: 'code_1', name: 'CB' } as any,
});
@ -307,7 +324,7 @@ describe('HistoryListPanel.vue', () => {
const wrapper = await factory();
await nextTick();
historyService.undoCodeBlock('code_1');
historyService.undo('codeBlock', 'code_1');
await nextTick();
const heads = wrapper.findAll('.m-editor-history-list-group-head');
@ -318,8 +335,7 @@ describe('HistoryListPanel.vue', () => {
});
test('点击页面初始项调用 editorService.gotoPageStep(0)', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
@ -380,8 +396,7 @@ describe('HistoryListPanel.vue', () => {
});
test('点击页面 update 记录的「查看差异」打开 diff 弹窗', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'update',
modifiedNodeIds: new Map(),
diff: [
@ -401,8 +416,7 @@ describe('HistoryListPanel.vue', () => {
});
test('点击页面 update 记录的「回滚」透传到 onPageRevert', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'update',
modifiedNodeIds: new Map(),
diff: [
@ -423,8 +437,7 @@ describe('HistoryListPanel.vue', () => {
});
test('回滚目标校验由 useHistoryRevert 处理,面板仅透传 index', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
pushPage({
opType: 'update',
modifiedNodeIds: new Map(),
diff: [
@ -443,9 +456,8 @@ describe('HistoryListPanel.vue', () => {
expect(editorService.revertPageStep).not.toHaveBeenCalled();
});
test('确认清空页面历史后调用 historyService.clearPage', async () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
test('确认清空页面历史后调用 historyService.clear', async () => {
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
@ -458,17 +470,17 @@ describe('HistoryListPanel.vue', () => {
await wrapper.find('.m-editor-history-list-clear').trigger('click');
await nextTick();
expect(historyService.getPageHistoryGroups()).toHaveLength(0);
expect(historyService.getHistoryGroups('page', 'p1')).toHaveLength(0);
expect(saveSpy).toHaveBeenCalled();
saveSpy.mockRestore();
});
test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => {
historyService.pushDataSource('ds_x', {
pushDataSource('ds_x', {
oldSchema: null,
newSchema: { id: 'ds_x', title: 'DS' } as any,
});
historyService.pushCodeBlock('code_x', {
pushCodeBlock('code_x', {
oldContent: null,
newContent: { id: 'code_x', name: 'CB' } as any,
});
@ -491,11 +503,11 @@ describe('HistoryListPanel.vue', () => {
});
test('点击数据源 update 记录的「查看差异」与「回滚」', async () => {
historyService.pushDataSource('ds_1', {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any,
});
historyService.pushDataSource('ds_1', {
pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', title: '旧 DS' } as any,
newSchema: { id: 'ds_1', title: '新 DS' } as any,
changeRecords: [{ propPath: 'title', value: '新 DS' }],
@ -513,12 +525,12 @@ describe('HistoryListPanel.vue', () => {
expect(dataSourceService.revert).not.toHaveBeenCalled();
});
test('确认清空数据源/代码块历史后调用 clearDataSource / clearCodeBlock', async () => {
historyService.pushDataSource('ds_1', {
test('确认清空数据源/代码块历史后调用 clear(undefined, stepType)', async () => {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any,
});
historyService.pushCodeBlock('code_1', {
pushCodeBlock('code_1', {
oldContent: null,
newContent: { id: 'code_1', name: 'CB' } as any,
});
@ -534,8 +546,8 @@ describe('HistoryListPanel.vue', () => {
await clears[1].trigger('click');
await nextTick();
expect(historyService.getDataSourceHistoryGroups()).toHaveLength(0);
expect(historyService.getCodeBlockHistoryGroups()).toHaveLength(0);
expect(historyService.getHistoryGroups('dataSource')).toHaveLength(0);
expect(historyService.getHistoryGroups('codeBlock')).toHaveLength(0);
expect(saveSpy).toHaveBeenCalled();
saveSpy.mockRestore();
});

View File

@ -8,7 +8,7 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import PageTab from '@editor/layouts/history-list/PageTab.vue';
import type { PageHistoryGroup } from '@editor/type';
import type { HistoryGroup, StepValue } from '@editor/type';
vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({
@ -27,9 +27,9 @@ const buildPageGroup = (
targetName?: string,
targetId?: string,
startIndex = 0,
): PageHistoryGroup => ({
): HistoryGroup<StepValue> => ({
kind: 'page',
pageId: 'p1',
id: 'p1',
opType,
applied,
targetId,

View File

@ -19,13 +19,32 @@ import {
} from '@editor/layouts/history-list/composables';
import { useHistoryList } from '@editor/layouts/history-list/useHistoryList';
import historyService from '@editor/services/history';
import type { PageHistoryGroup, PageHistoryStepEntry, StepValue } from '@editor/type';
import type { HistoryGroup, HistoryStepEntry, StepValue } from '@editor/type';
import { createStackStep } from '@editor/utils/history';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
const pushCodeBlock = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
});
return step ? historyService.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
});
return step ? historyService.push('dataSource', step as any, id) : null;
};
afterEach(() => {
historyService.reset();
});
const buildPageEntry = (step: StepValue, index = 0, applied = true): PageHistoryStepEntry => ({
const buildPageEntry = (step: StepValue, index = 0, applied = true): HistoryStepEntry<StepValue> => ({
step,
index,
applied,
@ -187,9 +206,9 @@ describe('describePageStep', () => {
describe('describePageGroup', () => {
test('historyDescription 取最后一条非空的描述', () => {
const group: PageHistoryGroup = {
const group: HistoryGroup<StepValue> = {
kind: 'page',
pageId: 'p1',
id: 'p1',
opType: 'update',
targetId: 'btn_1',
targetName: '按钮',
@ -208,9 +227,9 @@ describe('describePageGroup', () => {
opType: 'update',
diff: [{ newSchema: { id: 'a', name: 'A' }, oldSchema: { id: 'a' } }],
} as unknown as StepValue;
const group: PageHistoryGroup = {
const group: HistoryGroup<StepValue> = {
kind: 'page',
pageId: 'p1',
id: 'p1',
opType: 'update',
targetId: 'a',
targetName: 'A',
@ -233,9 +252,9 @@ describe('describePageGroup', () => {
],
}) as unknown as StepValue;
const group: PageHistoryGroup = {
const group: HistoryGroup<StepValue> = {
kind: 'page',
pageId: 'p1',
id: 'p1',
opType: 'update',
targetId: 'btn_1',
targetName: '按钮',
@ -258,9 +277,9 @@ describe('describePageGroup', () => {
],
}) as unknown as StepValue;
const group: PageHistoryGroup = {
const group: HistoryGroup<StepValue> = {
kind: 'page',
pageId: 'p1',
id: 'p1',
opType: 'update',
targetId: 'btn_1',
targetName: '按钮',
@ -282,9 +301,9 @@ describe('describePageGroup', () => {
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
}) as unknown as StepValue;
const group: PageHistoryGroup = {
const group: HistoryGroup<StepValue> = {
kind: 'page',
pageId: 'p1',
id: 'p1',
opType: 'update',
targetId: 'btn_1',
targetName: '按钮',
@ -295,9 +314,9 @@ describe('describePageGroup', () => {
});
test('多步组 targetName 缺省时使用 targetId 兜底', () => {
const group: PageHistoryGroup = {
const group: HistoryGroup<StepValue> = {
kind: 'page',
pageId: 'p1',
id: 'p1',
opType: 'update',
targetId: 'btn_1',
applied: true,
@ -324,7 +343,11 @@ describe('useHistoryList', () => {
const wrapper = mount(host, {
global: {
provide: {
services: { historyService },
// useHistoryList 现在按当前页 id 取页面历史,提供 editorService.get('page') 返回固定 id 'p1'。
services: {
historyService,
editorService: { get: (k?: string) => (k === 'page' ? { id: 'p1' } : undefined) },
},
},
},
});
@ -345,17 +368,24 @@ describe('useHistoryList', () => {
test('pageGroupsDisplay按时间倒序', () => {
const { api } = mountWithHost();
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.push({
opType: 'remove',
diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.push(
'page',
{
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any,
'p1',
);
historyService.push(
'page',
{
opType: 'remove',
diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(),
} as any,
'p1',
);
expect(api.pageGroups.value).toHaveLength(2);
// 正序:最早的 add 在前;倒序展示:最新的 remove 在前
@ -366,15 +396,15 @@ describe('useHistoryList', () => {
test('dataSourceGroupsByTarget按 id 聚拢,每 bucket 内倒序', () => {
const { api } = mountWithHost();
historyService.pushDataSource('ds_1', {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', title: 'A' } as any,
});
historyService.pushDataSource('ds_1', {
pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', title: 'A' } as any,
newSchema: { id: 'ds_1', title: 'A2' } as any,
});
historyService.pushDataSource('ds_2', {
pushDataSource('ds_2', {
oldSchema: null,
newSchema: { id: 'ds_2', title: 'B' } as any,
});
@ -394,11 +424,11 @@ describe('useHistoryList', () => {
test('codeBlockGroupsByTarget按 id 聚拢', () => {
const { api } = mountWithHost();
historyService.pushCodeBlock('code_1', {
pushCodeBlock('code_1', {
oldContent: null,
newContent: { id: 'code_1', name: 'fn' } as any,
});
historyService.pushCodeBlock('code_2', {
pushCodeBlock('code_2', {
oldContent: null,
newContent: { id: 'code_2', name: 'fn2' } as any,
});

View File

@ -27,6 +27,7 @@ vi.mock('@editor/layouts/history-list/composables', async () => {
const createServices = () =>
({
editorService: {
get: vi.fn(() => ({ id: 'p1' })),
getNodeById: vi.fn(),
revertPageStep: vi.fn(async () => null),
},
@ -39,12 +40,8 @@ const createServices = () =>
revert: vi.fn(async () => null),
},
historyService: {
getPageStepList: vi.fn(() => []),
getDataSourceStepList: vi.fn(() => []),
getCodeBlockStepList: vi.fn(() => []),
getPageHistoryGroups: vi.fn(() => []),
getDataSourceHistoryGroups: vi.fn(() => []),
getCodeBlockHistoryGroups: vi.fn(() => []),
getStepList: vi.fn(() => []),
getHistoryGroups: vi.fn(() => []),
},
}) as any;
@ -55,7 +52,7 @@ afterEach(() => {
describe('useHistoryRevert', () => {
test('页面 update 记录的目标节点已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getPageStepList.mockReturnValue([
services.historyService.getStepList.mockReturnValue([
{
step: {
opType: 'update',
@ -74,7 +71,7 @@ describe('useHistoryRevert', () => {
test('页面 add 记录回滚时走普通二次确认,并执行 revertPageStep', async () => {
const services = createServices();
services.historyService.getPageStepList.mockReturnValue([
services.historyService.getStepList.mockReturnValue([
{
step: {
opType: 'add',
@ -92,7 +89,7 @@ describe('useHistoryRevert', () => {
test('数据源 update 记录对应目标已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getDataSourceStepList.mockReturnValue([
services.historyService.getStepList.mockReturnValue([
{
step: {
opType: 'update',

View File

@ -174,8 +174,8 @@ describe('CodeBlockService - 历史记录接入', () => {
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSync('new_code', { name: 'A' } as any);
expect(historyService.canUndoCodeBlock('new_code')).toBe(true);
const step = historyService.undoCodeBlock('new_code');
expect(historyService.canUndo('codeBlock', 'new_code')).toBe(true);
const step = historyService.undo('codeBlock', 'new_code');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A' }));
});
@ -184,7 +184,7 @@ describe('CodeBlockService - 历史记录接入', () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
const step = historyService.undo('codeBlock', 'a');
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A2' }));
});
@ -192,14 +192,14 @@ describe('CodeBlockService - 历史记录接入', () => {
test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any, false);
expect(historyService.canUndoCodeBlock('a')).toBe(false);
expect(historyService.canUndo('codeBlock', 'a')).toBe(false);
});
test('deleteCodeDslByIds - 删除已存在的代码块入历史newContent=null', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
await codeBlockService.deleteCodeDslByIds(['a']);
const step = historyService.undoCodeBlock('a');
const step = historyService.undo('codeBlock', 'a');
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
});
@ -207,7 +207,7 @@ describe('CodeBlockService - 历史记录接入', () => {
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
await codeBlockService.setCodeDsl({} as any);
await codeBlockService.deleteCodeDslByIds(['ghost']);
expect(historyService.canUndoCodeBlock('ghost')).toBe(false);
expect(historyService.canUndo('codeBlock', 'ghost')).toBe(false);
});
test('setCodeDslByIdSync - 携带 changeRecords 时写入历史 step', async () => {
@ -217,7 +217,7 @@ describe('CodeBlockService - 历史记录接入', () => {
changeRecords: [{ propPath: 'name', value: 'A2' }],
});
const step = historyService.undoCodeBlock('a');
const step = historyService.undo('codeBlock', 'a');
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
});
@ -226,14 +226,14 @@ describe('CodeBlockService - 历史记录接入', () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
const step = historyService.undo('codeBlock', 'a');
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
});
});
describe('CodeBlockService - *AndGetHistoryId', () => {
const lastStepUuid = (id: string) => {
const list = historyService.getCodeBlockStepList(id);
const list = historyService.getStepList('codeBlock', id);
return list[list.length - 1]?.step.uuid;
};
@ -311,7 +311,7 @@ describe('CodeBlockService - revertById', () => {
await codeBlockService.setCodeDsl({} as any);
const { historyIds } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
const location = historyService.findCodeBlockStepLocationByUuid(historyIds[0]!);
const location = historyService.findStepLocationByUuid('codeBlock', historyIds[0]!);
expect(location).toEqual({ id: 'a', index: 0 });
});
@ -433,14 +433,14 @@ describe('CodeBlockService - undo / redo', () => {
historyService.reset();
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
expect(historyService.canUndoCodeBlock('a')).toBe(true);
expect(historyService.canUndo('codeBlock', 'a')).toBe(true);
await codeBlockService.undo('a');
expect(historyService.canRedoCodeBlock('a')).toBe(true);
expect(historyService.canRedo('codeBlock', 'a')).toBe(true);
await codeBlockService.redo('a');
expect(historyService.canRedoCodeBlock('a')).toBe(false);
expect(historyService.canUndoCodeBlock('a')).toBe(true);
expect(historyService.canRedo('codeBlock', 'a')).toBe(false);
expect(historyService.canUndo('codeBlock', 'a')).toBe(true);
});
test('canUndo / canRedo 委托给 historyService', async () => {

View File

@ -133,8 +133,8 @@ describe('DataSource service', () => {
describe('DataSource service - 历史记录接入', () => {
test('add - 入历史oldSchema=null', () => {
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
expect(historyService.canUndoDataSource(ds.id!)).toBe(true);
const step = historyService.undoDataSource(ds.id!);
expect(historyService.canUndo('dataSource', ds.id!)).toBe(true);
const step = historyService.undo('dataSource', ds.id!);
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema?.title).toBe('a');
});
@ -145,7 +145,7 @@ describe('DataSource service - 历史记录接入', () => {
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
const step = historyService.undo('dataSource', created.id!);
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
expect(step?.diff?.[0]?.newSchema?.title).toBe('b');
});
@ -155,14 +155,14 @@ describe('DataSource service - 历史记录接入', () => {
historyService.reset();
dataSource.remove(created.id!);
const step = historyService.undoDataSource(created.id!);
const step = historyService.undo('dataSource', created.id!);
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
});
test('remove - 不存在的 id 不入历史', () => {
dataSource.remove('ghost');
expect(historyService.canUndoDataSource('ghost')).toBe(false);
expect(historyService.canUndo('dataSource', 'ghost')).toBe(false);
});
test('update - 携带 changeRecords 时写入历史 step', () => {
@ -173,7 +173,7 @@ describe('DataSource service - 历史记录接入', () => {
changeRecords: [{ propPath: 'title', value: 'b' }],
});
const step = historyService.undoDataSource(created.id!);
const step = historyService.undo('dataSource', created.id!);
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
});
@ -182,14 +182,14 @@ describe('DataSource service - 历史记录接入', () => {
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
const step = historyService.undo('dataSource', created.id!);
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
});
});
describe('DataSource service - *AndGetHistoryId', () => {
const lastStepUuid = (id: string) => {
const list = historyService.getDataSourceStepList(id);
const list = historyService.getStepList('dataSource', id);
return list[list.length - 1]?.step.uuid;
};
@ -238,7 +238,7 @@ describe('DataSource service - *AndGetHistoryId', () => {
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;
const uuid = historyService.getStepList('dataSource', created.id!).slice(-1)[0]?.step.uuid;
expect(typeof uuid).toBe('string');
expect(dataSource.getDataSourceById(created.id!)).toBeDefined();
@ -249,9 +249,9 @@ describe('DataSource service - revertById', () => {
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 uuid = historyService.getStepList('dataSource', created.id!).slice(-1)[0]?.step.uuid;
const location = historyService.findDataSourceStepLocationByUuid(uuid!);
const location = historyService.findStepLocationByUuid('dataSource', uuid!);
expect(location).toEqual({ id: created.id, index: 0 });
});
@ -264,8 +264,8 @@ describe('DataSource service - revertById', () => {
test('支持传入 uuid 数组并按顺序回滚', () => {
const ds1 = dataSource.add({ title: 'a', type: 'base' } as any);
const ds2 = dataSource.add({ title: 'b', type: 'base' } as any);
const uuid1 = historyService.getDataSourceStepList(ds1.id!).slice(-1)[0]?.step.uuid;
const uuid2 = historyService.getDataSourceStepList(ds2.id!).slice(-1)[0]?.step.uuid;
const uuid1 = historyService.getStepList('dataSource', ds1.id!).slice(-1)[0]?.step.uuid;
const uuid2 = historyService.getStepList('dataSource', ds2.id!).slice(-1)[0]?.step.uuid;
const reverted = dataSource.revertById([uuid1!, uuid2!]);
expect(reverted).toHaveLength(2);
@ -383,15 +383,15 @@ describe('DataSource service - undo / redo', () => {
dataSource.update({ ...created, title: 'b' } as any);
// 此时栈里只有一条 update
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
expect(historyService.canUndo('dataSource', created.id!)).toBe(true);
dataSource.undo(created.id!);
// undo 后栈应可 redo并且 undo 不应再生新栈记录
expect(historyService.canRedoDataSource(created.id!)).toBe(true);
expect(historyService.canRedo('dataSource', created.id!)).toBe(true);
dataSource.redo(created.id!);
expect(historyService.canRedoDataSource(created.id!)).toBe(false);
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
expect(historyService.canRedo('dataSource', created.id!)).toBe(false);
expect(historyService.canUndo('dataSource', created.id!)).toBe(true);
});
test('canUndo / canRedo 委托给 historyService', () => {

View File

@ -825,7 +825,7 @@ describe('undo redo', () => {
describe('*AndGetHistoryId', () => {
const lastStepUuid = () => {
const list = historyService.getPageStepList();
const list = historyService.getStepList('page', editorService.get('page')?.id);
return list[list.length - 1]?.step.uuid;
};
@ -896,7 +896,9 @@ describe('revertPageStepById', () => {
const uuid = historyIds[0];
expect(typeof uuid).toBe('string');
const addedStep = historyService.getPageStepList().find((e) => e.step.uuid === uuid)!.step;
const addedStep = historyService
.getStepList('page', editorService.get('page')?.id)
.find((e) => e.step.uuid === uuid)!.step;
const addedId = addedStep.diff[0].newSchema!.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
@ -913,8 +915,8 @@ describe('revertPageStepById', () => {
const { historyIds } = await editorService.addAndGetHistoryId({ type: 'text' });
const uuid = historyIds[0];
const index = historyService.getPageStepIndexByUuid(uuid!);
expect(index).toBeGreaterThanOrEqual(0);
const location = historyService.findStepLocationByUuid('page', uuid!, editorService.get('page')?.id);
expect(location?.index).toBeGreaterThanOrEqual(0);
});
test('找不到 uuid 时返回 null', async () => {
@ -935,8 +937,11 @@ describe('revertPageStepById', () => {
const { historyIds: ids2 } = await editorService.addAndGetHistoryId({ type: 'text' });
const uuids = [ids1[0]!, ids2[0]!];
const addedId1 = historyService.getPageStepList().find((e) => e.step.uuid === uuids[0])!.step.diff[0].newSchema!.id;
const addedId2 = historyService.getPageStepList().find((e) => e.step.uuid === uuids[1])!.step.diff[0].newSchema!.id;
const pageId = editorService.get('page')?.id;
const addedId1 = historyService.getStepList('page', pageId).find((e) => e.step.uuid === uuids[0])!.step.diff[0]
.newSchema!.id;
const addedId2 = historyService.getStepList('page', pageId).find((e) => e.step.uuid === uuids[1])!.step.diff[0]
.newSchema!.id;
const reverted = await editorService.revertPageStepById(uuids);
expect(reverted).toHaveLength(2);

View File

@ -19,8 +19,27 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vi
import history from '@editor/services/history';
import { setEditorConfig } from '@editor/utils/config';
import { createStackStep } from '@editor/utils/history';
import * as indexedDb from '@editor/utils/indexed-db';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
const pushCodeBlock = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
});
return step ? history.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
});
return step ? history.push('dataSource', step as any, id) : null;
};
// 用内存实现 mock 掉 IndexedDB 读写工具,避免依赖真实 IndexedDBhappy-dom 不提供)。
vi.mock('@editor/utils/indexed-db', () => {
const store = new Map<string, any>();
@ -59,126 +78,131 @@ describe('history service - markSaved', () => {
test('markSaved 派发 mark-saved 事件并带 kind=all', () => {
const fn = vi.fn();
history.on('mark-saved', fn);
history.markSaved();
history.markSaved('page');
expect(fn).toHaveBeenCalledWith({ kind: 'all' });
history.off('mark-saved', fn);
});
test('markPageSaved / markCodeBlockSaved / markDataSourceSaved 派发对应 kind 事件', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
test('markSaved(stepType, id) 派发对应 kind 事件', () => {
history.push('page', pageStep(), 'p1');
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const pageFn = vi.fn();
const codeFn = vi.fn();
history.on('mark-saved', (payload) => {
if (payload.kind === 'page') pageFn(payload);
if (payload.kind === 'code-block') codeFn(payload);
if (payload.kind === 'codeBlock') codeFn(payload);
});
history.markPageSaved();
history.markCodeBlockSaved('code_1');
history.markSaved('page', 'p1');
history.markSaved('codeBlock', 'code_1');
expect(pageFn).toHaveBeenCalledWith({ kind: 'page', id: 'p1' });
expect(codeFn).toHaveBeenCalledWith({ kind: 'code-block', id: 'code_1' });
expect(codeFn).toHaveBeenCalledWith({ kind: 'codeBlock', id: 'code_1' });
});
});
describe('history service - clear', () => {
test('clearPage 清空当前页面历史并复位 canUndo/canRedo', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push(pageStep());
expect(history.state.canUndo).toBe(true);
test('clear 清空指定页面历史并复位 canUndo/canRedo', () => {
history.push('page', pageStep(), 'p1');
history.push('page', pageStep(), 'p1');
expect(history.canUndo('page', 'p1')).toBe(true);
history.clearPage();
expect((history.state.pageSteps as any).p1.getLength()).toBe(0);
expect(history.state.canUndo).toBe(false);
expect(history.state.canRedo).toBe(false);
history.clear('page', 'p1');
expect((history.state.steps.page as any).p1.getLength()).toBe(0);
expect(history.canUndo('page', 'p1')).toBe(false);
expect(history.canRedo('page', 'p1')).toBe(false);
});
test('clearCodeBlock 传 id 清单个,缺省清全部', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
test('clear 保留被清空栈的 initial 基线', () => {
history.setMarker('page', 'p1', { name: 'P1', description: '初始' });
history.push('page', pageStep(), 'p1');
expect((history.state.steps.page as any).p1.getLength()).toBe(2);
history.clearCodeBlock('code_1');
expect((history.state.codeBlockState as any).code_1).toBeUndefined();
expect((history.state.codeBlockState as any).code_2).toBeDefined();
history.clearCodeBlock();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
history.clear('page', 'p1');
// 真实操作记录被清空,仅保留 index 0 的 initial 基线
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
expect(history.getMarker('page', 'p1')?.opType).toBe('initial');
expect(history.canUndo('page', 'p1')).toBe(false);
});
test('clearDataSource 传 id 清单个,缺省清全部', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
test('clear 传 id 清单个,缺省清全部codeBlock', () => {
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
history.clearDataSource('ds_1');
expect((history.state.dataSourceState as any).ds_1).toBeUndefined();
expect((history.state.dataSourceState as any).ds_2).toBeDefined();
history.clear('codeBlock', 'code_1');
expect((history.state.steps.codeBlock as any).code_1.getLength()).toBe(0);
expect((history.state.steps.codeBlock as any).code_2.getLength()).toBe(1);
history.clearDataSource();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
history.clear('codeBlock');
expect((history.state.steps.codeBlock as any).code_2.getLength()).toBe(0);
});
test('clear 传 id 清单个缺省清全部dataSource', () => {
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
history.clear('dataSource', 'ds_1');
expect((history.state.steps.dataSource as any).ds_1.getLength()).toBe(0);
expect((history.state.steps.dataSource as any).ds_2.getLength()).toBe(1);
history.clear('dataSource');
expect((history.state.steps.dataSource as any).ds_2.getLength()).toBe(0);
});
});
describe('history service - IndexedDB 持久化', () => {
test('saveToIndexedDB 以对象写入(仅 step.diff 序列化成字符串)并返回快照对象', async () => {
history.changePage({ id: 'p1' } as any);
history.push({ ...pageStep(), diff: [{ newSchema: { id: 'n1', name: '节点' } }] } as any);
history.push('page', { ...pageStep(), diff: [{ newSchema: { id: 'n1', name: '节点' } }] } as any, 'p1');
const snapshot = await history.saveToIndexedDB();
expect(snapshot.version).toBe(2);
expect(snapshot.pageId).toBe('p1');
expect(snapshot.version).toBe(3);
// 实际写入 IndexedDB 的是对象(交给结构化克隆),仅每条 step 的 diff 被序列化成字符串
expect(indexedDb.idbSet).toHaveBeenCalled();
const written = (indexedDb.idbSet as any).mock.calls[0][3];
expect(typeof written).toBe('object');
expect(typeof written.pageSteps.p1.elementList[0].diff).toBe('string');
expect(typeof written.steps.page.p1.elementList[0].diff).toBe('string');
// diff 之外的字段(如 modifiedNodeIds Map原样交给结构化克隆不被字符串化
expect(written.pageSteps.p1.elementList[0].modifiedNodeIds instanceof Map).toBe(true);
expect(written.steps.page.p1.elementList[0].modifiedNodeIds instanceof Map).toBe(true);
// 返回的快照即写入 IndexedDB 的持久化形态diff 已是序列化字符串
expect(written).toBe(snapshot);
expect(typeof snapshot.pageSteps.p1.elementList[0].diff).toBe('string');
expect(typeof snapshot.steps.page.p1.elementList[0].diff).toBe('string');
});
test('restoreFromIndexedDB 还原页面 / 代码块 / 数据源全部栈与游标', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push(pageStep());
history.undo(); // page cursor = 1
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.push('page', pageStep(), 'p1');
history.push('page', pageStep(), 'p1');
history.undo('page', 'p1'); // page cursor = 1
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
await history.saveToIndexedDB();
history.reset();
expect(Object.keys(history.state.pageSteps)).toHaveLength(0);
expect(Object.keys(history.state.steps.page)).toHaveLength(0);
const restored = await history.restoreFromIndexedDB();
expect(restored).not.toBeNull();
expect(history.state.pageId).toBe('p1');
expect(history.getPageCursor('p1')).toBe(1);
expect((history.state.codeBlockState as any).code_1).toBeDefined();
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
expect(history.getCursor('page', 'p1')).toBe(1);
expect((history.state.steps.codeBlock as any).code_1).toBeDefined();
expect((history.state.steps.dataSource as any).ds_1).toBeDefined();
});
test('restoreFromIndexedDB 把游标恢复到最近一个已保存记录', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push(pageStep());
history.markPageSaved(); // 标记 index 1cursor=2
history.push(pageStep()); // cursor=3saved 仍在 index 1
history.push('page', pageStep(), 'p1');
history.push('page', pageStep(), 'p1');
history.markSaved('page', 'p1'); // 标记 index 1cursor=2
history.push('page', pageStep(), 'p1'); // cursor=3saved 仍在 index 1
await history.saveToIndexedDB();
history.reset();
await history.restoreFromIndexedDB();
// 恢复后游标定位到已保存记录之后index 1 -> cursor 2
expect(history.getPageCursor('p1')).toBe(2);
expect(history.getCursor('page', 'p1')).toBe(2);
});
test('restoreFromIndexedDB 能还原内容中的函数serialize + parseDSL 往返)', async () => {
history.pushCodeBlock('code_1', {
pushCodeBlock('code_1', {
oldContent: null,
newContent: {
name: 'A',
@ -192,19 +216,18 @@ describe('history service - IndexedDB 持久化', () => {
history.reset();
await history.restoreFromIndexedDB();
const current = (history.state.codeBlockState as any).code_1.getCurrentElement();
const current = (history.state.steps.codeBlock as any).code_1.getCurrentElement();
expect(typeof current.diff[0].newSchema.code).toBe('function');
expect(current.diff[0].newSchema.code()).toBe(42);
});
test('restoreFromIndexedDB 找不到记录时返回 null 且不改动当前状态', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push('page', pageStep(), 'p1');
const restored = await history.restoreFromIndexedDB();
expect(restored).toBeNull();
// 当前状态保持不变
expect((history.state.pageSteps as any).p1.getLength()).toBe(1);
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
});
test('saveToIndexedDB 派发 save-to-indexed-db、restoreFromIndexedDB 派发 restore-from-indexed-db', async () => {
@ -213,8 +236,7 @@ describe('history service - IndexedDB 持久化', () => {
history.on('save-to-indexed-db', saveFn);
history.on('restore-from-indexed-db', restoreFn);
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push('page', pageStep(), 'p1');
await history.saveToIndexedDB();
await history.restoreFromIndexedDB();

View File

@ -6,330 +6,344 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import history from '@editor/services/history';
import { createStackStep } from '@editor/utils/history';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id)。
// 这里用与旧便捷方法等价的小工具,按 payload 构造 step 后走新的 push便于沿用既有用例。
const pushCodeBlock = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
});
return step ? history.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
});
return step ? history.push('dataSource', step as any, id) : null;
};
afterEach(() => {
history.reset();
});
describe('history service', () => {
test('changePage 切换页面会创建对应的 UndoRedo', () => {
history.changePage({ id: 'p1' } as any);
expect((history.state.pageSteps as any).p1).toBeDefined();
});
test('push / undo / redo 链路', () => {
history.changePage({ id: 'p1' } as any);
const v1 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'a' } as any;
const v2 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'b' } as any;
history.push(v1);
history.push(v2);
history.push('page', v1, 'p1');
history.push('page', v2, 'p1');
const undone = history.undo();
const undone = history.undo('page', 'p1');
expect(undone).toBeDefined();
const redone = history.redo();
const redone = history.redo('page', 'p1');
expect(redone).toBeDefined();
});
test('未指定 pageId 时 push/undo/redo 返回 null', () => {
history.resetPage();
expect(history.push({} as any)).toBeNull();
expect(history.undo()).toBeNull();
expect(history.redo()).toBeNull();
test('未传 / 无效 id 时 push/undo/redo 返回 null', () => {
expect(history.push('page', {} as any, '')).toBeNull();
expect(history.undo('page', '')).toBeNull();
expect(history.redo('page', '')).toBeNull();
});
test('reset / resetPage / resetState', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: {} } as any);
test('reset / resetState 清空页面栈', () => {
history.push('page', { data: {} } as any, 'p1');
history.reset();
expect(history.state.pageId).toBeUndefined();
expect(Object.keys(history.state.pageSteps)).toHaveLength(0);
expect(Object.keys(history.state.steps.page)).toHaveLength(0);
});
test('canUndo / canRedo 在 push 后更新', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: {} } as any);
history.push({ data: {} } as any);
expect(history.state.canUndo).toBe(true);
history.push('page', { data: {} } as any, 'p1');
history.push('page', { data: {} } as any, 'p1');
expect(history.canUndo('page', 'p1')).toBe(true);
});
test('changePage 接到 undefined/null 时不变更', () => {
history.changePage(null as any);
expect(history.state.pageId).toBeUndefined();
});
test('push 指定 pageId 落到目标页栈,不影响当前页', () => {
// 当前激活在 p1
history.changePage({ id: 'p1' } as any);
test('push 指定 pageId 落到目标页栈,不影响其它页', () => {
const step = { data: { id: 'p2', name: '' }, modifiedNodeIds: new Map() } as any;
// 跨页 push把记录推到 p2目标页p1 栈应保持为空
history.push(step, 'p2');
expect((history.state.pageSteps as any).p2).toBeDefined();
expect((history.state.pageSteps as any).p2.canUndo()).toBe(true);
// p1 栈虽然激活但没有 push 进来,仍不可撤销
expect((history.state.pageSteps as any).p1.canUndo()).toBe(false);
history.push('page', step, 'p2');
expect((history.state.steps.page as any).p2).toBeDefined();
expect(history.canUndo('page', 'p2')).toBe(true);
// p1 栈没有 push 进来,仍不可撤销
expect(history.canUndo('page', 'p1')).toBe(false);
// 跨页 push 不应触发当前页p1的 canUndo 改变
expect(history.state.canUndo).toBe(false);
// 切到 p2 后能正常 undo 该跨页步骤
history.changePage({ id: 'p2' } as any);
expect(history.state.canUndo).toBe(true);
expect(history.undo()).toBeDefined();
});
test('push 不传 pageId 时落到当前活动页栈(向后兼容)', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect((history.state.pageSteps as any).p1.canUndo()).toBe(true);
expect(history.state.canUndo).toBe(true);
// p2 能正常 undo 该步骤
expect(history.undo('page', 'p2')).toBeDefined();
});
test('push 未带 timestamp 时自动写入入栈时间', () => {
history.changePage({ id: 'p1' } as any);
const before = Date.now();
const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
const step = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('push 已带 timestamp 时保留调用方指定的值', () => {
history.changePage({ id: 'p1' } as any);
const step = history.push({
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
timestamp: 123456,
} as any);
const step = history.push(
'page',
{
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
timestamp: 123456,
} as any,
'p1',
);
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);
const step = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
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);
const step = history.push(
'page',
{
uuid: 'my-uuid',
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
} as any,
'p1',
);
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);
const s1 = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
const s2 = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
expect(s1?.uuid).toBeTruthy();
expect(s2?.uuid).toBeTruthy();
expect(s1?.uuid).not.toBe(s2?.uuid);
});
test('setPageMarker 在空栈时种入 initial 基线', () => {
history.changePage({ id: 'p1' } as any);
const marker = history.setPageMarker('p1', { name: '首页', description: '初始' });
test('setMarker 在空栈时种入 initial 基线', () => {
const marker = history.setMarker('page', 'p1', { name: '首页', description: '初始' });
expect(marker?.opType).toBe('initial');
expect(history.getPageMarker('p1')?.uuid).toBe(marker?.uuid);
expect((history.state.pageSteps as any).p1.getLength()).toBe(1);
expect(history.getMarker('page', 'p1')?.uuid).toBe(marker?.uuid);
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
});
test('有 initial 基线时不可撤销越过基线', () => {
history.changePage({ id: 'p1' } as any);
history.setPageMarker('p1');
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
history.setMarker('page', 'p1');
history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
expect(history.state.canUndo).toBe(true);
history.undo();
expect(history.state.canUndo).toBe(false);
expect(history.undo()).toBeNull();
expect(history.getPageCursor('p1')).toBe(1);
expect(history.canUndo('page', 'p1')).toBe(true);
history.undo('page', 'p1');
expect(history.canUndo('page', 'p1')).toBe(false);
expect(history.undo('page', 'p1')).toBeNull();
expect(history.getCursor('page', 'p1')).toBe(1);
});
test('getPageHistoryGroups 过滤 initial 基线', () => {
history.changePage({ id: 'p1' } as any);
history.setPageMarker('p1');
history.push({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
test('扩展类型同样支持 initial 基线(撤销不越过基线)', () => {
history.setMarker('custom', 'ext_1', {});
history.push('custom', { opType: 'update', diff: [] } as any, 'ext_1');
const groups = history.getPageHistoryGroups('p1');
expect(history.canUndo('custom', 'ext_1')).toBe(true);
history.undo('custom', 'ext_1');
expect(history.canUndo('custom', 'ext_1')).toBe(false);
expect(history.undo('custom', 'ext_1')).toBeNull();
});
test('getHistoryGroups 过滤 initial 基线', () => {
history.setMarker('page', 'p1');
history.push(
'page',
{
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any,
'p1',
);
const groups = history.getHistoryGroups('page', 'p1');
expect(groups).toHaveLength(1);
expect(groups[0].opType).toBe('add');
});
});
describe('history service - codeBlock', () => {
test('pushCodeBlock 入栈并触发 code-block-history-change 事件', () => {
test('pushCodeBlock 入栈并触发 change 事件', () => {
const fn = vi.fn();
history.on('code-block-history-change', fn);
history.on('change', fn);
const step = history.pushCodeBlock('code_1', {
const step = pushCodeBlock('code_1', {
oldContent: null,
newContent: { name: 'A', content: 'x' } as any,
});
expect(step).not.toBeNull();
expect(step?.id).toBe('code_1');
expect(step?.data?.id).toBe('code_1');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
expect((history.state.codeBlockState as any).code_1).toBeDefined();
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('code_1', expect.objectContaining({ id: 'code_1' }));
expect((history.state.steps.codeBlock as any).code_1).toBeDefined();
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
expect(fn).toHaveBeenCalledWith(
expect.objectContaining({ data: { name: 'A', id: 'code_1' } }),
'codeBlock',
'code_1',
);
history.off('code-block-history-change', fn);
history.off('change', fn);
});
test('pushCodeBlock 不传 id 返回 null', () => {
expect(history.pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
expect(pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
});
test('pushCodeBlock 自动写入入栈时间戳', () => {
const before = Date.now();
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const step = pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushCodeBlock 自动生成 uuid', () => {
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const step = 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', {
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_1', {
oldContent: { name: 'A' } as any,
newContent: { name: 'B' } as any,
});
expect(history.canUndoCodeBlock('code_1')).toBe(true);
const undone = history.undoCodeBlock('code_1');
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
const undone = history.undo('codeBlock', 'code_1');
expect(undone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
expect(history.canRedoCodeBlock('code_1')).toBe(true);
expect(history.canRedo('codeBlock', 'code_1')).toBe(true);
const redone = history.redoCodeBlock('code_1');
const redone = history.redo('codeBlock', 'code_1');
expect(redone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
});
test('undoCodeBlock 对不存在 id 返回 null', () => {
expect(history.undoCodeBlock('not-exist')).toBeNull();
expect(history.redoCodeBlock('not-exist')).toBeNull();
expect(history.canUndoCodeBlock('not-exist')).toBe(false);
expect(history.canRedoCodeBlock('not-exist')).toBe(false);
expect(history.undo('codeBlock', 'not-exist')).toBeNull();
expect(history.redo('codeBlock', 'not-exist')).toBeNull();
expect(history.canUndo('codeBlock', 'not-exist')).toBe(false);
expect(history.canRedo('codeBlock', 'not-exist')).toBe(false);
});
test('不同代码块 id 的栈相互隔离', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(history.canUndoCodeBlock('code_2')).toBe(true);
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
expect(history.canUndo('codeBlock', 'code_2')).toBe(true);
history.undoCodeBlock('code_1');
expect(history.canUndoCodeBlock('code_1')).toBe(false);
history.undo('codeBlock', 'code_1');
expect(history.canUndo('codeBlock', 'code_1')).toBe(false);
// code_2 的栈不受影响
expect(history.canUndoCodeBlock('code_2')).toBe(true);
expect(history.canUndo('codeBlock', 'code_2')).toBe(true);
});
test('reset / resetState 清空 codeBlockState', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.reset();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
expect(Object.keys(history.state.steps.codeBlock)).toHaveLength(0);
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.resetState();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
expect(Object.keys(history.state.steps.codeBlock)).toHaveLength(0);
});
});
describe('history service - dataSource', () => {
test('pushDataSource 入栈并触发 data-source-history-change 事件', () => {
test('pushDataSource 入栈并触发 change 事件', () => {
const fn = vi.fn();
history.on('data-source-history-change', fn);
history.on('change', fn);
const step = history.pushDataSource('ds_1', {
const step = pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
expect(step).not.toBeNull();
expect(step?.id).toBe('ds_1');
expect(step?.data?.id).toBe('ds_1');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema?.title).toBe('A');
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
expect(history.canUndoDataSource('ds_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('ds_1', expect.objectContaining({ id: 'ds_1' }));
expect((history.state.steps.dataSource as any).ds_1).toBeDefined();
expect(history.canUndo('dataSource', 'ds_1')).toBe(true);
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ data: { name: 'A', id: 'ds_1' } }), 'dataSource', 'ds_1');
history.off('data-source-history-change', fn);
history.off('change', fn);
});
test('pushDataSource 不传 id 返回 null', () => {
expect(history.pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
expect(pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
});
test('pushDataSource 自动写入入栈时间戳', () => {
const before = Date.now();
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
const step = pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushDataSource 自动生成 uuid', () => {
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
const step = 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', {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
history.pushDataSource('ds_1', {
pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
newSchema: { id: 'ds_1', type: 'base', title: 'B' } as any,
});
const undone = history.undoDataSource('ds_1');
const undone = history.undo('dataSource', 'ds_1');
expect(undone?.diff?.[0]?.newSchema?.title).toBe('B');
const redone = history.redoDataSource('ds_1');
const redone = history.redo('dataSource', 'ds_1');
expect(redone?.diff?.[0]?.newSchema?.title).toBe('B');
});
test('undoDataSource 对不存在 id 返回 null', () => {
expect(history.undoDataSource('not-exist')).toBeNull();
expect(history.redoDataSource('not-exist')).toBeNull();
expect(history.canUndoDataSource('not-exist')).toBe(false);
expect(history.canRedoDataSource('not-exist')).toBe(false);
expect(history.undo('dataSource', 'not-exist')).toBeNull();
expect(history.redo('dataSource', 'not-exist')).toBeNull();
expect(history.canUndo('dataSource', 'not-exist')).toBe(false);
expect(history.canRedo('dataSource', 'not-exist')).toBe(false);
});
test('不同数据源 id 的栈相互隔离', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
history.undoDataSource('ds_1');
expect(history.canUndoDataSource('ds_1')).toBe(false);
expect(history.canUndoDataSource('ds_2')).toBe(true);
history.undo('dataSource', 'ds_1');
expect(history.canUndo('dataSource', 'ds_1')).toBe(false);
expect(history.canUndo('dataSource', 'ds_2')).toBe(true);
});
test('reset / resetState 清空 dataSourceState', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.reset();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
expect(Object.keys(history.state.steps.dataSource)).toHaveLength(0);
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.resetState();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
expect(Object.keys(history.state.steps.dataSource)).toHaveLength(0);
});
});

View File

@ -10,13 +10,12 @@ import {
createStackStep,
describeRevertStep,
deserializeStacks,
detectPageTargetId,
detectPageTargetName,
detectStackOpType,
detectTargetId,
detectTargetName,
getOrCreateStack,
markStackSaved,
mergePageSteps,
mergeStackSteps,
mergeSteps,
serializeStacks,
undoFloor,
} from '@editor/utils/history';
@ -121,13 +120,13 @@ describe('markStackSaved', () => {
});
});
describe('mergeStackSteps', () => {
test('连续 update 各自独立成组', () => {
describe('mergeSteps(代码块 / 数据源等按 id 分栈类型)', () => {
test('无 diff 的连续 update 不合并(无明确目标)', () => {
const list = [
{ opType: 'update', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeStackSteps('code-block', 'code_1', list, 2);
const groups = mergeSteps('code-block', 'code_1', list, 2);
expect(groups).toHaveLength(2);
expect(groups[0].steps).toHaveLength(1);
expect(groups[1].steps).toHaveLength(1);
@ -135,12 +134,32 @@ describe('mergeStackSteps', () => {
expect(groups[1].opType).toBe('update');
});
test('同目标连续 update 合并成一组CodeBlockContent 无 id回退 step.data.id', () => {
const mkUpdate = (path: string) =>
({
opType: 'update',
data: { name: 'A', id: 'code_1' },
diff: [
{
newSchema: { name: 'A', content: 'x' },
oldSchema: { name: 'A', content: 'x' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as CodeBlockStepValue;
const groups = mergeSteps('code-block', 'code_1', [mkUpdate('content'), mkUpdate('params')], 2);
expect(groups).toHaveLength(1);
expect(groups[0].steps).toHaveLength(2);
expect(groups[0].id).toBe('code_1');
expect(groups[0].kind).toBe('code-block');
});
test('add / update 各自独立成组', () => {
const list = [
{ opType: 'add', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeStackSteps('code-block', 'code_1', list, 2);
const groups = mergeSteps('code-block', 'code_1', list, 2);
expect(groups).toHaveLength(2);
});
@ -149,7 +168,7 @@ describe('mergeStackSteps', () => {
{ opType: 'update', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeStackSteps('code-block', 'code_1', list, 1);
const groups = mergeSteps('code-block', 'code_1', list, 1);
expect(groups[0].applied).toBe(true);
expect(groups[0].steps[0].applied).toBe(true);
expect(groups[0].steps[0].isCurrent).toBe(true);
@ -159,14 +178,14 @@ describe('mergeStackSteps', () => {
});
});
describe('detectPageTargetId / detectPageTargetName', () => {
describe('detectTargetId / detectTargetName', () => {
test('单节点 update 返回 targetId 与名称', () => {
const step = {
opType: 'update',
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
} as unknown as StepValue;
expect(detectPageTargetId(step)).toBe('btn_1');
expect(detectPageTargetName(step)).toBe('按钮');
expect(detectTargetId(step)).toBe('btn_1');
expect(detectTargetName(step)).toBe('按钮');
});
test('多节点 update 不参与合并', () => {
@ -177,8 +196,8 @@ describe('detectPageTargetId / detectPageTargetName', () => {
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
],
} as unknown as StepValue;
expect(detectPageTargetId(step)).toBeUndefined();
expect(detectPageTargetName(step)).toBe('2 个节点');
expect(detectTargetId(step)).toBeUndefined();
expect(detectTargetName(step)).toBe('2 个节点');
});
test('add 单节点返回名称', () => {
@ -186,12 +205,12 @@ describe('detectPageTargetId / detectPageTargetName', () => {
opType: 'add',
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
} as unknown as StepValue;
expect(detectPageTargetId(step)).toBeUndefined();
expect(detectPageTargetName(step)).toBe('text');
expect(detectTargetId(step)).toBeUndefined();
expect(detectTargetName(step)).toBe('text');
});
});
describe('mergePageSteps', () => {
describe('mergeSteps(页面)', () => {
test('相邻同 targetId 的 update 合并', () => {
const mkUpdate = (path: string) =>
({
@ -206,7 +225,7 @@ describe('mergePageSteps', () => {
}) as unknown as StepValue;
const list = [mkUpdate('style.color'), mkUpdate('style.fontSize')];
const groups = mergePageSteps('p1', list, 2);
const groups = mergeSteps('page', 'p1', list, 2);
expect(groups).toHaveLength(1);
expect(groups[0].targetId).toBe('btn_1');
expect(groups[0].steps).toHaveLength(2);
@ -223,7 +242,7 @@ describe('mergePageSteps', () => {
diff: [{ newSchema: { id: 'n1', name: 'A' }, oldSchema: { id: 'n1', name: 'A' } }],
},
] as unknown as StepValue[];
const groups = mergePageSteps('p1', list, 2);
const groups = mergeSteps('page', 'p1', list, 2);
expect(groups).toHaveLength(2);
expect(groups[0].opType).toBe('add');
expect(groups[1].opType).toBe('update');
@ -240,7 +259,7 @@ describe('mergePageSteps', () => {
diff: [{ newSchema: { id: 'n1', name: '新名' }, oldSchema: { id: 'n1', name: '旧名' } }],
},
] as unknown as StepValue[];
const groups = mergePageSteps('p1', list, 2);
const groups = mergeSteps('page', 'p1', list, 2);
expect(groups[0].targetName).toBe('新名');
});
});

View File

@ -100,7 +100,7 @@ const save = () => {
localStorage.setItem('magicDSL', serializeConfig(toRaw(value.value)));
editor.value?.editorService.resetModifiedNodeId();
// IndexedDB
historyService.markSaved();
historyService.markSaved('page');
};
const { menu, deviceGroup, iframe, previewVisible } = useEditorMenu(value, save);
@ -138,7 +138,7 @@ const schedulePersist = debounce(persistHistory, 500);
// IndexedDB root value restore
// DSL id app
// select(defaultSelected) changePage
// select(defaultSelected) push
const restoreHistory = async () => {
try {
await historyService.restoreFromIndexedDB({ appId: dsl.id });
@ -153,7 +153,7 @@ const rootChangeHandler = (
{ historySource }: { historySource?: HistoryOpSource } = {},
) => {
if (historySource === 'initial') {
Object.values(historyService.state.pageSteps).forEach(markStackSaved);
Object.values(historyService.state.steps).forEach((steps) => Object.values(steps).forEach(markStackSaved));
}
};
@ -176,8 +176,6 @@ onMounted(async () => {
}
historyService.on('change', schedulePersist);
historyService.on('code-block-history-change', schedulePersist);
historyService.on('data-source-history-change', schedulePersist);
window.addEventListener('beforeunload', persistHistory);
window.addEventListener('pagehide', persistHistory);
});
@ -186,8 +184,6 @@ onBeforeUnmount(() => {
schedulePersist.cancel();
persistHistory();
historyService.off('change', schedulePersist);
historyService.off('code-block-history-change', schedulePersist);
historyService.off('data-source-history-change', schedulePersist);
window.removeEventListener('beforeunload', persistHistory);
window.removeEventListener('pagehide', persistHistory);
editorService.removeAllPlugins();