mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-01 22:08:18 +08:00
refactor(editor): 统一历史栈结构,支持扩展历史类型
将 pageSteps/codeBlockState/dataSourceState 三套独立历史栈收敛为统一的 steps 结构 (按 stepType 分桶),并新增 registerStepType/setStepName/getStepName 支持自定义 扩展历史类型。同步重构 history 相关服务、组件、工具方法、测试与文档。
This commit is contained in:
parent
bfdaf2b244
commit
0f42989ca3
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
- **详情:**
|
||||
|
||||
销毁
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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));
|
||||
};
|
||||
|
||||
|
||||
@ -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 逻辑
|
||||
|
||||
@ -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 逻辑
|
||||
|
||||
@ -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
@ -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` 恒为单元素。
|
||||
* 把指定历史栈的步骤列表按"目标"做相邻合并:
|
||||
* - 连续修改同一目标(单实体 update,targetId 一致)的多步合并成一组,组内可展开查看每步;
|
||||
* - 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;
|
||||
/** 所属栈 id(page 为 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',
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 读写工具,避免依赖真实 IndexedDB(happy-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 1(cursor=2)
|
||||
history.push(pageStep()); // cursor=3,saved 仍在 index 1
|
||||
history.push('page', pageStep(), 'p1');
|
||||
history.push('page', pageStep(), 'p1');
|
||||
history.markSaved('page', 'p1'); // 标记 index 1(cursor=2)
|
||||
history.push('page', pageStep(), 'p1'); // cursor=3,saved 仍在 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();
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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('新名');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user