diff --git a/docs/api/editor/codeBlockServiceMethods.md b/docs/api/editor/codeBlockServiceMethods.md index 292b06a9..7d145463 100644 --- a/docs/api/editor/codeBlockServiceMethods.md +++ b/docs/api/editor/codeBlockServiceMethods.md @@ -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 diff --git a/docs/api/editor/dataSourceServiceMethods.md b/docs/api/editor/dataSourceServiceMethods.md index df86dfa2..9a8a7e11 100644 --- a/docs/api/editor/dataSourceServiceMethods.md +++ b/docs/api/editor/dataSourceServiceMethods.md @@ -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 diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index 401f6df0..279509aa 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -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} diff --git a/docs/api/editor/historyServiceEvents.md b/docs/api/editor/historyServiceEvents.md index c5f992dd..c502ce98 100644 --- a/docs/api/editor/historyServiceEvents.md +++ b/docs/api/editor/historyServiceEvents.md @@ -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 diff --git a/docs/api/editor/historyServiceMethods.md b/docs/api/editor/historyServiceMethods.md index 73c47076..1720bb69 100644 --- a/docs/api/editor/historyServiceMethods.md +++ b/docs/api/editor/historyServiceMethods.md @@ -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 - **详情:** 销毁 - diff --git a/packages/editor/src/components/CompareForm.vue b/packages/editor/src/components/CompareForm.vue index 8180c38a..9404cf75 100644 --- a/packages/editor/src/components/CompareForm.vue +++ b/packages/editor/src/components/CompareForm.vue @@ -189,7 +189,11 @@ const defaultLoadConfig = async (): Promise => { ); } 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({ diff --git a/packages/editor/src/layouts/NavMenu.vue b/packages/editor/src/layouts/NavMenu.vue index cac122a5..a707688a 100644 --- a/packages/editor/src/layouts/NavMenu.vue +++ b/packages/editor/src/layouts/NavMenu.vue @@ -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; diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue index 73800b38..0b4b5c6b 100644 --- a/packages/editor/src/layouts/history-list/HistoryListPanel.vue +++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue @@ -18,7 +18,7 @@ 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 = { - title: '数据源', +const dataSourceConfig = computed>(() => ({ + 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 = { - title: '代码块', +const codeBlockConfig = computed>(() => ({ + 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(); } }; diff --git a/packages/editor/src/layouts/history-list/PageTab.vue b/packages/editor/src/layouts/history-list/PageTab.vue index 70335eb6..60215e9e 100644 --- a/packages/editor/src/layouts/history-list/PageTab.vue +++ b/packages/editor/src/layouts/history-list/PageTab.vue @@ -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[]; /** * 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护。 * 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组, @@ -105,9 +105,9 @@ const descriptor: HistoryRowDescriptor = { isStepRevertable: isPageStepRevertable, }; -const rowKey = (group: PageHistoryGroup) => `pg-${group.steps[0]?.index}`; +const rowKey = (group: HistoryGroup) => `pg-${group.steps[0]?.index}`; -const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor); +const toRow = (group: HistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor); /** * 是否处于"初始状态"——即对应页面历史栈 cursor===0: diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts index b3fdd6ef..c03ab82b 100644 --- a/packages/editor/src/layouts/history-list/composables.ts +++ b/packages/editor/src/layouts/history-list/composables.ts @@ -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 { 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) => { 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); diff --git a/packages/editor/src/layouts/history-list/useHistoryList.ts b/packages/editor/src/layouts/history-list/useHistoryList.ts index 7114b5f7..c495ca1e 100644 --- a/packages/editor/src/layouts/history-list/useHistoryList.ts +++ b/packages/editor/src/layouts/history-list/useHistoryList.ts @@ -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()); diff --git a/packages/editor/src/layouts/history-list/useHistoryRevert.ts b/packages/editor/src/layouts/history-list/useHistoryRevert.ts index a50e5776..64991975 100644 --- a/packages/editor/src/layouts/history-list/useHistoryRevert.ts +++ b/packages/editor/src/layouts/history-list/useHistoryRevert.ts @@ -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 | 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 | 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 | 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)); }; diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index 2ac18af8..185cb00e 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -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(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(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 { - 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 { - 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 { - 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 { - 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(entry.step.id, entry.step.diff?.[0], (s) => s.name)}`; + const description = `回滚 #${index + 1}: ${describeRevertStep(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 { - 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 { - const { id } = step; + const { id } = step.data; const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {}; // 新增 / 删除:直接 set 或 delete,不走 patch 逻辑 diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index a8ce89da..91098ebb 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -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(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(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(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(entry.step.id, entry.step.diff?.[0], (s) => s.title)}`; + const description = `回滚 #${index + 1}: ${describeRevertStep(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 逻辑 diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 1f837b34..002c4284 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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 { - 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 { - 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 { - 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 { - 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; diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index b424bea5..8b1c3d1a 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -19,31 +19,30 @@ import { reactive } from 'vue'; import type { Writable } from 'type-fest'; -import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core'; -import type { ChangeRecord } from '@tmagic/form'; +import type { Id } from '@tmagic/core'; import { guid } from '@tmagic/utils'; import type { + BaseStepValue, CodeBlockStepValue, DataSourceStepValue, + HistoryEvents, + HistoryGroup, HistoryOpSource, HistoryPersistOptions, HistoryState, - PageHistoryGroup, - PageHistoryStepEntry, + HistoryStepEntry, + HistoryStepType, PersistedHistoryState, - StackHistoryGroup, StepValue, SyncHookPlugin, } from '@editor/type'; import { getEditorConfig } from '@editor/utils/config'; import { - createStackStep, deserializeStacks, getOrCreateStack, markStackSaved, - mergePageSteps, - mergeStackSteps, + mergeSteps, serializeStacks, undoFloor, } from '@editor/utils/history'; @@ -54,17 +53,21 @@ import BaseService from './BaseService'; import editorService from './editor'; const canUsePluginMethods = { - sync: [ - 'push', - 'pushCodeBlock', - 'pushDataSource', - 'undoCodeBlock', - 'redoCodeBlock', - 'undoDataSource', - 'redoDataSource', - 'undo', - 'redo', - ] as const, + sync: ['push', 'undo', 'redo'] as const, +}; + +/** 各内置历史类型的默认展示名称(用于历史面板 tab / 分组标题等)。扩展类型可通过 registerStepType / setStepName 登记。 */ +const DEFAULT_STEP_NAMES: Record = { + page: '页面', + codeBlock: '代码块', + dataSource: '数据源', +}; + +/** 各历史类型对应的分组 `kind`(展示用):page→page,codeBlock→code-block,dataSource→data-source。扩展类型缺省回退到 stepType 本身。 */ +const STEP_TYPE_KIND: Record = { + page: 'page', + codeBlock: 'code-block', + dataSource: 'data-source', }; type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; @@ -73,82 +76,86 @@ type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; const DEFAULT_DB_NAME = 'tmagic-editor'; const DEFAULT_STORE_NAME = 'history'; const DEFAULT_KEY: IDBValidKey = 'default'; -// v2:仅把每条 step 的 diff 序列化成字符串,其余字段交给 IndexedDB 结构化克隆原生存储(见 saveToIndexedDB)。 -const PERSIST_VERSION = 2; +// 仅把每条 step 的 diff 序列化成字符串,其余字段交给 IndexedDB 结构化克隆原生存储(见 saveToIndexedDB); +// 全部历史栈统一收敛到 steps 字段(见 HistorySteps)。 +const PERSIST_VERSION = 3; class History extends BaseService { public state = reactive({ - pageSteps: {}, - pageId: undefined, - canRedo: false, - canUndo: false, - codeBlockState: {}, - dataSourceState: {}, + steps: { + page: {}, + codeBlock: {}, + dataSource: {}, + }, + stepNames: { ...DEFAULT_STEP_NAMES }, }); constructor() { super([...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))]); - - this.on('change', this.setCanUndoRedo); - } - - public reset() { - this.state.pageSteps = {}; - this.state.codeBlockState = {}; - this.state.dataSourceState = {}; - this.resetPage(); - } - - public resetPage() { - this.state.pageId = undefined; - this.state.canRedo = false; - this.state.canUndo = false; - } - - public changePage(page: MPage | MPageFragment): void { - if (!page) return; - - this.state.pageId = page.id; - - if (!this.state.pageSteps[this.state.pageId]) { - this.state.pageSteps[this.state.pageId] = new UndoRedo(); - } - - this.setCanUndoRedo(); - - this.emit('page-change', this.state.pageSteps[this.state.pageId]); - } - - public resetState(): void { - this.state.pageId = undefined; - this.state.pageSteps = {}; - this.state.canRedo = false; - this.state.canUndo = false; - this.state.codeBlockState = {}; - this.state.dataSourceState = {}; } /** - * 为指定页面 / 页面片种入一条「初始基线」记录(如加载 DSL 时的「初始 / 加载」基线)。 + * 注册一个扩展历史类型,使其可与内置 `page` / `codeBlock` / `dataSource` 一样走统一的 + * {@link push} / {@link undo} / {@link redo}(按 id 分栈、独立 undo/redo)。 * - * 该记录是一条 `opType: 'initial'` 的 {@link StepValue},作为页面历史栈 **index 0 的固定底线**: - * - 它是一条真实入栈的 step(随栈一起持久化),但被钉为撤销/回滚的下限——cursor 永不低于它, - * 因此不会被 undo / goto / revert 触达(详见 {@link undo} / {@link setCanUndoRedo}); - * - 历史面板把它过滤出分组列表(见 {@link getPageHistoryGroups}),改由底部「初始」行展示。 - * - * 仅当目标页面栈为空时种入(保证 initial 一定位于 index 0);已存在 initial 底线时默认不重复种入, - * 传 `force=true` 且栈为空时按新基线种入。 + * @param stepType 自定义历史类型标识(勿与内置类型重名) + * @param options.event push/undo/redo 后派发的事件名,缺省为 `${stepType}-history-change` + * @param options.name 历史面板中的展示名称(tab / 分组标题等),缺省回退到 stepType 本身 */ - public setPageMarker( - pageId: Id, - options: { name?: string; description?: string; source?: HistoryOpSource } = {}, - ): StepValue | null { - if (pageId === undefined || pageId === null || `${pageId}` === '') return null; + public registerStepType(stepType: string, options: { event?: string; name?: string } = {}): void { + this.getStepBucket(stepType); + if (options.name !== undefined) this.state.stepNames[stepType] = options.name; + } - const existing = this.getPageMarker(pageId); + /** + * 读取指定历史类型的展示名称(用于历史面板 tab / 分组标题等)。 + * 未登记名称时回退到 stepType 本身。 + */ + public getStepName(stepType: HistoryStepType): string { + return this.state.stepNames[stepType] ?? `${stepType}`; + } + + /** + * 设置指定历史类型的展示名称(用于历史面板 tab / 分组标题等)。 + * 内置 `page` / `codeBlock` / `dataSource` 也可在此覆盖默认中文名。 + */ + public setStepName(stepType: HistoryStepType, name: string): void { + this.state.stepNames[stepType] = name; + } + + public reset() { + this.clearAllSteps(); + } + + public resetState(): void { + this.clearAllSteps(); + } + + /** + * 为指定历史栈(默认 `page`,也适用于 codeBlock / dataSource / 扩展类型)种入一条「初始基线」记录。 + * + * 该记录是一条 `opType: 'initial'` 的 step,作为对应栈 **index 0 的固定底线**: + * - 它是一条真实入栈的 step(随栈一起持久化),但被钉为撤销/回滚的下限——cursor 永不低于它, + * 因此不会被 undo / goto / revert 触达(详见 {@link undo} / {@link undoFloor}); + * - 历史面板把它过滤出分组列表(见 {@link getHistoryGroups}),改由底部「初始」行展示。 + * + * 仅当目标栈为空时种入(保证 initial 一定位于 index 0);已存在 initial 底线时不重复种入。 + * + * @param stepType 历史类型 + * @param id 目标栈 id(page 为 pageId,其余类型为对应资源 id) + * @param options 基线展示信息(名称 / 描述 / 来源) + */ + public setMarker( + stepType: HistoryStepType, + id: Id, + options: { name?: string; description?: string; source?: HistoryOpSource; extra?: Record } = {}, + ): StepValue | null { + if (!this.isValidStackId(id)) return null; + + const existing = this.getMarker(stepType, id); if (existing) return existing; - const stack = getOrCreateStack(this.state.pageSteps, pageId); + const stack = getOrCreateStack(this.getStepBucket(stepType), id); // initial 必须是 index 0;栈非空(已有真实记录、却无 initial,如旧数据)时不强行前插,优雅降级为无基线。 if (stack.getLength() > 0) return null; @@ -156,205 +163,141 @@ class History extends BaseService { uuid: guid(), opType: 'initial', diff: [], - data: { name: options.name || '', id: pageId }, - selectedBefore: [], - selectedAfter: [], - modifiedNodeIds: new Map(), + data: { name: options.name || '', id }, + extra: { + ...(options.extra || {}), + ...(stepType === 'page' + ? { + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + } + : {}), + }, historyDescription: options.description || '未修改的初始状态', timestamp: Date.now(), ...(options.source ? { source: options.source } : {}), }; stack.pushElement(marker); - if (`${pageId}` === `${this.state.pageId}`) this.setCanUndoRedo(); - this.emit('page-marker-change', marker); + this.emit('marker-change', { id, marker, stepType }); return marker; } /** - * 读取指定页面(缺省当前活动页)的初始基线 step(页面栈 index 0 且 `opType: 'initial'`); - * 不存在时返回 undefined。 + * 读取指定历史栈的初始基线 step(栈 index 0 且 `opType: 'initial'`); + * 不存在或 id 缺省时返回 undefined。 */ - public getPageMarker(pageId?: Id): StepValue | undefined { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return undefined; - const first = this.state.pageSteps[targetPageId]?.getElementList()[0]; - return first?.opType === 'initial' ? first : undefined; + public getMarker(stepType: HistoryStepType, id?: Id): StepValue | undefined { + if (!this.isValidStackId(id)) return undefined; + const first = this.state.steps[stepType]?.[id]?.getElementList()[0]; + return first?.opType === 'initial' ? (first as StepValue) : undefined; } /** - * 把一条步骤推入指定页面的栈;不指定 pageId 时落到当前活动页。 + * 把一条步骤推入指定历史栈。统一入口,所有类型(page / codeBlock / dataSource / 扩展)行为完全一致: + * 按 `stepType` 选择目标栈类型,按 `id`(必填)选择具体栈,按需建栈后入栈,并派发对应的历史变更事件 + * (`page` 为 `change`,其余见 {@link registerStepType}),回调签名统一为 `(id, step)`。 * - * 跨页操作(例如 `moveToContainer` 把节点搬到其它页)必须显式传入 `pageId`, - * 否则会把记录错误地落到操作发起页 / 当前激活页,破坏目标页 / 源页的撤销栈语义。 + * 跨页 / 跨资源操作(如 `moveToContainer` 把节点搬到其它页)必须显式传入目标 id, + * 否则无法落到正确的栈。step 可由 `createStackStep` 等构造后传入。 + * + * @param stepType 历史类型 + * @param step 已构造好的历史记录(缺省自动补全 uuid / timestamp) + * @param id 目标栈 id(page 为 pageId,其余类型为对应资源 id),必填 */ - public push(state: StepValue, pageId?: Id): StepValue | null { - const undoRedo = this.getUndoRedo(pageId); - if (!undoRedo) return null; - if (state.uuid === undefined) state.uuid = guid(); - if (state.timestamp === undefined) state.timestamp = Date.now(); - undoRedo.pushElement(state); - // 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。 - if (pageId === undefined || `${pageId}` === `${this.state.pageId}`) { - this.emit('change', state); - } - return state; + public push(stepType: 'page', step: StepValue, id: Id): StepValue | null; + public push(stepType: 'codeBlock', step: CodeBlockStepValue, id: Id): CodeBlockStepValue | null; + public push(stepType: 'dataSource', step: DataSourceStepValue, id: Id): DataSourceStepValue | null; + public push(stepType: string, step: T, id: Id): T | null; + public push(stepType: HistoryStepType, step: BaseStepValue, id: Id): BaseStepValue | null { + if (!this.isValidStackId(id)) return null; + this.fillStepMeta(step); + getOrCreateStack(this.getStepBucket(stepType), id).pushElement(step); + this.emit('change', step, stepType, id); + return step; } - /** 读取指定页面(缺省当前活动页)历史栈当前游标所在的 step(cursor - 1);无则返回 null。 */ - public getCurrentPageStep(pageId?: Id): StepValue | null { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return null; - return this.state.pageSteps[targetPageId]?.getCurrentElement() ?? null; + /** + * 撤销指定历史栈的最近一次变更。统一入口,所有类型行为一致: + * 按 `id`(必填)+ `stepType` 定位栈,不会越过 index 0 的 initial 基线(所有类型同等适用), + * 仅在确有可撤销 step 时派发对应的历史变更事件(`page` 为 `change`,回调签名 `(id, step)`)。 + * + * @param stepType 历史类型 + * @param id 目标栈 id(page 为 pageId,其余类型为对应资源 id),必填 + */ + public undo(stepType: 'page', id: Id): StepValue | null; + public undo(stepType: 'codeBlock', id: Id): CodeBlockStepValue | null; + public undo(stepType: 'dataSource', id: Id): DataSourceStepValue | null; + public undo(stepType: string, id: Id): T | null; + public undo(stepType: HistoryStepType, id: Id): BaseStepValue | null { + if (!this.isValidStackId(id)) return null; + const undoRedo = this.state.steps[stepType]?.[id]; + if (!undoRedo) return null; + // 不允许撤销越过初始基线(index 0 的 initial step);无基线时 floor 为 0。 + if (undoRedo.getCursor() <= undoFloor(undoRedo)) return null; + const step = undoRedo.undo(); + if (step) this.emit('change', step, stepType, id); + return step; + } + + /** + * 重做指定历史栈的下一次变更。语义与 {@link undo} 对称,详见其说明。 + * + * @param stepType 历史类型 + * @param id 目标栈 id(page 为 pageId,其余类型为对应资源 id),必填 + */ + public redo(stepType: 'page', id: Id): StepValue | null; + public redo(stepType: 'codeBlock', id: Id): CodeBlockStepValue | null; + public redo(stepType: 'dataSource', id: Id): DataSourceStepValue | null; + public redo(stepType: string, id: Id): T | null; + public redo(stepType: HistoryStepType, id: Id): BaseStepValue | null { + if (!this.isValidStackId(id)) return null; + const undoRedo = this.state.steps[stepType]?.[id]; + if (!undoRedo) return null; + const step = undoRedo.redo(); + if (step) this.emit('change', step, stepType, id); + return step; + } + + /** + * 是否可对指定历史栈撤销(cursor 高于 initial 基线底线)。 + * @param stepType 历史类型 + * @param id 目标栈 id;缺省 / 无效时返回 false + */ + public canUndo(stepType: HistoryStepType, id?: Id): boolean { + if (!this.isValidStackId(id)) return false; + const undoRedo = this.state.steps[stepType]?.[id]; + return undoRedo ? undoRedo.getCursor() > undoFloor(undoRedo) : false; + } + + /** + * 是否可对指定历史栈重做。 + * @param stepType 历史类型 + * @param id 目标栈 id;缺省 / 无效时返回 false + */ + public canRedo(stepType: HistoryStepType, id?: Id): boolean { + if (!this.isValidStackId(id)) return false; + return this.state.steps[stepType]?.[id]?.canRedo() ?? false; + } + + /** 读取指定页面历史栈当前游标所在的 step(cursor - 1);无则返回 null。 */ + public getCurrentPageStep(pageId: Id): StepValue | null { + if (!this.isValidStackId(pageId)) return null; + return this.state.steps.page[pageId]?.getCurrentElement() ?? null; } /** * 用 `state` 替换指定页面栈当前游标所在的 step(并丢弃其后的重做尾部),游标不变。 - * 用于「连续 set root 记录合并」等就地替换最新一条的场景;替换成功后按需刷新 / 通知。 + * 用于「连续 set root 记录合并」等就地替换最新一条的场景;替换成功后派发 `change`。 */ - public replaceCurrentPageStep(state: StepValue, pageId?: Id): StepValue | null { - const undoRedo = this.getUndoRedo(pageId); - if (!undoRedo) return null; - if (state.uuid === undefined) state.uuid = guid(); - if (state.timestamp === undefined) state.timestamp = Date.now(); + public replaceCurrentStep(stepType: HistoryStepType, state: StepValue, id: Id): StepValue | null { + if (!this.isValidStackId(id)) return null; + const undoRedo = getOrCreateStack(this.getStepBucket(stepType), id); + + this.fillStepMeta(state); + if (!undoRedo.replaceCurrentElement(state)) return null; - this.emit('change', state); - return state; - } - - /** - * 推入一条代码块变更记录(与页面/节点完全无关),按 `codeBlockId` 维度独立一份 UndoRedo 栈。 - * - * - 新增:oldContent = null,newContent = 新内容 - * - 更新:oldContent / newContent 都为对应内容 - * - 删除:newContent = null,oldContent = 删除前内容 - * - `changeRecords` 来自 form 端,撤销/重做时若有则按 propPath 局部覆盖;缺省才退化为整内容替换。 - * - 不直接驱动 codeBlockService,调用方负责实际写回。 - */ - public pushCodeBlock( - codeBlockId: Id, - payload: { - oldContent: CodeBlockContent | null; - newContent: CodeBlockContent | null; - changeRecords?: ChangeRecord[]; - /** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */ - historyDescription?: string; - /** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */ - source?: HistoryOpSource; - }, - ): CodeBlockStepValue | null { - const step = createStackStep(codeBlockId, { - oldValue: payload.oldContent, - newValue: payload.newContent, - changeRecords: payload.changeRecords, - historyDescription: payload.historyDescription, - source: payload.source, - }); - if (!step) return null; - getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step); - this.emit('code-block-history-change', codeBlockId, step); - return step; - } - - /** - * 推入一条数据源变更记录(与页面/节点完全无关),按 `dataSourceId` 维度独立一份 UndoRedo 栈。 - * 行为同 pushCodeBlock(新增 oldSchema=null;删除 newSchema=null)。 - */ - public pushDataSource( - dataSourceId: Id, - payload: { - oldSchema: DataSourceSchema | null; - newSchema: DataSourceSchema | null; - changeRecords?: ChangeRecord[]; - /** 可选的人类可读描述,仅用于历史面板展示。 */ - historyDescription?: string; - /** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */ - source?: HistoryOpSource; - }, - ): DataSourceStepValue | null { - const step = createStackStep(dataSourceId, { - oldValue: payload.oldSchema, - newValue: payload.newSchema, - changeRecords: payload.changeRecords, - historyDescription: payload.historyDescription, - source: payload.source, - }); - if (!step) return null; - getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step); - this.emit('data-source-history-change', dataSourceId, step); - return step; - } - - /** 撤销指定代码块的最近一次变更。 */ - public undoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null { - const undoRedo = this.state.codeBlockState[codeBlockId]; - if (!undoRedo) return null; - const step = undoRedo.undo(); - if (step) this.emit('code-block-history-change', codeBlockId, step); - return step; - } - - /** 重做指定代码块的下一次变更。 */ - public redoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null { - const undoRedo = this.state.codeBlockState[codeBlockId]; - if (!undoRedo) return null; - const step = undoRedo.redo(); - if (step) this.emit('code-block-history-change', codeBlockId, step); - return step; - } - - /** 是否可对指定代码块撤销。 */ - public canUndoCodeBlock(codeBlockId: Id): boolean { - return this.state.codeBlockState[codeBlockId]?.canUndo() ?? false; - } - - /** 是否可对指定代码块重做。 */ - public canRedoCodeBlock(codeBlockId: Id): boolean { - return this.state.codeBlockState[codeBlockId]?.canRedo() ?? false; - } - - /** 撤销指定数据源的最近一次变更。 */ - public undoDataSource(dataSourceId: Id): DataSourceStepValue | null { - const undoRedo = this.state.dataSourceState[dataSourceId]; - if (!undoRedo) return null; - const step = undoRedo.undo(); - if (step) this.emit('data-source-history-change', dataSourceId, step); - return step; - } - - /** 重做指定数据源的下一次变更。 */ - public redoDataSource(dataSourceId: Id): DataSourceStepValue | null { - const undoRedo = this.state.dataSourceState[dataSourceId]; - if (!undoRedo) return null; - const step = undoRedo.redo(); - if (step) this.emit('data-source-history-change', dataSourceId, step); - return step; - } - - /** 是否可对指定数据源撤销。 */ - public canUndoDataSource(dataSourceId: Id): boolean { - return this.state.dataSourceState[dataSourceId]?.canUndo() ?? false; - } - - /** 是否可对指定数据源重做。 */ - public canRedoDataSource(dataSourceId: Id): boolean { - return this.state.dataSourceState[dataSourceId]?.canRedo() ?? false; - } - - public undo(): StepValue | null { - const undoRedo = this.getUndoRedo(); - if (!undoRedo) return null; - // 不允许撤销越过初始基线(index 0 的 initial step)。 - if (undoRedo.getCursor() <= undoFloor(undoRedo)) return null; - const state = undoRedo.undo(); - this.emit('change', state); - return state; - } - - public redo(): StepValue | null { - const undoRedo = this.getUndoRedo(); - if (!undoRedo) return null; - const state = undoRedo.redo(); - this.emit('change', state); + this.emit('change', state, stepType, id); return state; } @@ -365,91 +308,53 @@ class History extends BaseService { } /** - * 清空指定页面(缺省当前活动页)的历史记录栈。 - * 仅删除撤销/重做记录,不会改动当前 DSL;清空后该页将无法再撤销/重做之前的操作。 + * 清空历史记录栈。统一入口,所有类型(page / codeBlock / dataSource / 扩展)行为一致: + * - 传入 `id`:仅清空 `stepType` 下该 id 对应的栈; + * - 缺省 `id`:清空 `stepType` 下的全部栈。 + * + * 仅删除撤销/重做记录,不会改动 DSL / 代码块 / 数据源本身。清空时会**保留各栈原有的 + * initial 基线**(文案 / 来源),无基线时清空成空栈。清空后派发 `clear`(签名 `(id, stepType)`)。 + * + * @param stepType 历史类型 + * @param id 目标栈 id;缺省表示清空该类型下全部栈 */ - public clearPage(pageId?: Id): void { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return; - // 保留该页原 initial 基线的文案 / 来源(仅清空其后的真实操作记录),无基线时清空成空栈。 - const marker = this.getPageMarker(targetPageId); - this.state.pageSteps[targetPageId] = new UndoRedo(); - if (marker) { - this.setPageMarker(targetPageId, { - name: marker.data?.name, - description: marker.historyDescription, - source: marker.source, - }); - } - if (`${targetPageId}` === `${this.state.pageId}`) { - this.setCanUndoRedo(); - this.emit('clear-page', null); - } - } + public clear(stepType: HistoryStepType, id?: Id): void { + const bucket = this.state.steps[stepType]; + if (!bucket) return; - /** - * 清空数据源历史记录栈:传入 `dataSourceId` 仅清空该数据源,缺省清空全部数据源。 - * 仅删除撤销/重做记录,不会改动数据源本身。 - */ - public clearDataSource(dataSourceId?: Id): void { - if (dataSourceId !== undefined) { - delete this.state.dataSourceState[dataSourceId]; + if (this.isValidStackId(id)) { + this.clearStack(stepType, id); + } else if (id === undefined) { + Object.keys(bucket).forEach((stackId) => this.clearStack(stepType, stackId as Id)); } else { - this.state.dataSourceState = {}; + return; } + this.emit('clear', { id: id as Id, stepType }); } /** - * 清空代码块历史记录栈:传入 `codeBlockId` 仅清空该代码块,缺省清空全部代码块。 - * 仅删除撤销/重做记录,不会改动代码块本身。 + * 标记历史记录为「已保存」(把对应栈当前游标所在的记录标为 `saved`)。统一入口: + * - 缺省 `id`:标记**全部类型、全部栈**(整份 DSL 落库场景),派发 `mark-saved` 且 `kind: 'all'`; + * - 传入 `id`:仅标记 `stepType` 下该 id 对应的栈,派发 `mark-saved` 且 `kind` 为 `stepType`。 + * + * 同一栈内任意时刻最多只有一条记录为 `saved`;从 IndexedDB 恢复时游标会被定位到最近一条已保存记录之后。 + * + * @param stepType 历史类型 + * @param id 目标栈 id;缺省表示标记全部类型 / 全部栈 */ - public clearCodeBlock(codeBlockId?: Id): void { - if (codeBlockId !== undefined) { - delete this.state.codeBlockState[codeBlockId]; - } else { - this.state.codeBlockState = {}; + public markSaved(stepType: HistoryStepType, id?: Id): void { + if (id === undefined) { + Object.values(this.state.steps).forEach((bucket) => Object.values(bucket).forEach(markStackSaved)); + this.emit('mark-saved', { kind: 'all' }); + return; } + if (!this.isValidStackId(id)) return; + markStackSaved(this.state.steps[stepType]?.[id]); + this.emit('mark-saved', { kind: stepType, id }); } /** - * 标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标为 `saved`。 - * 适用于「整体落库」场景;若只保存了其中一类,请改用更细粒度的 - * {@link markPageSaved} / {@link markCodeBlockSaved} / {@link markDataSourceSaved}。 - */ - public markSaved(): void { - Object.values(this.state.pageSteps).forEach(markStackSaved); - Object.values(this.state.codeBlockState).forEach(markStackSaved); - Object.values(this.state.dataSourceState).forEach(markStackSaved); - this.emit('mark-saved', { kind: 'all' }); - } - - /** - * 标记指定页面(缺省为当前活动页)的历史栈当前记录为已保存。 - * 仅影响该页面自己的栈,不波及代码块 / 数据源 / 其它页面。 - */ - public markPageSaved(pageId?: Id): void { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return; - markStackSaved(this.state.pageSteps[targetPageId]); - this.emit('mark-saved', { kind: 'page', id: targetPageId }); - } - - /** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */ - public markCodeBlockSaved(codeBlockId: Id): void { - if (!codeBlockId) return; - markStackSaved(this.state.codeBlockState[codeBlockId]); - this.emit('mark-saved', { kind: 'code-block', id: codeBlockId }); - } - - /** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */ - public markDataSourceSaved(dataSourceId: Id): void { - if (!dataSourceId) return; - markStackSaved(this.state.dataSourceState[dataSourceId]); - this.emit('mark-saved', { kind: 'data-source', id: dataSourceId }); - } - - /** - * 把当前内存中的全部历史栈(页面 / 代码块 / 数据源)序列化后写入本地 IndexedDB。 + * 把当前内存中的全部历史栈(页面 / 代码块 / 数据源 / 扩展类型)序列化后写入本地 IndexedDB。 * * - 每个 UndoRedo 栈连同其游标、容量一并保存,恢复后可继续 undo/redo; * - `key` 用于区分不同活动页 / 项目(同一 store 下可保存多份快照),缺省为 `default`; @@ -461,12 +366,14 @@ class History extends BaseService { // serializeStacks 会在序列化各栈时只把每条 step 的 diff(可能含函数)序列化成字符串,其余字段原样保留, // 因此整份快照可直接按对象写入 IndexedDB(结构化克隆),避免序列化整份快照的开销;读取时再用 parseDSL 还原 diff。 + const steps: PersistedHistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} }; + Object.entries(this.state.steps).forEach(([stepType, bucket]) => { + steps[stepType] = serializeStacks(bucket); + }); + const snapshot: PersistedHistoryState = { version: PERSIST_VERSION, - pageId: this.state.pageId, - pageSteps: serializeStacks(this.state.pageSteps), - codeBlockState: serializeStacks(this.state.codeBlockState), - dataSourceState: serializeStacks(this.state.dataSourceState), + steps, savedAt: Date.now(), }; @@ -479,7 +386,7 @@ class History extends BaseService { * 从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。 * * - 读取到的每个栈都会经 {@link UndoRedo.fromSerialized} 还原(含游标),随后可直接 undo/redo; - * - 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 pageId; + * - 会整体覆盖当前内存中的历史状态(活动页由 editorService 维护,不在此恢复); * - 找不到对应记录时返回 null,且不改动当前状态; * - 不支持 IndexedDB 的环境(如 SSR)会 reject。 */ @@ -491,96 +398,35 @@ class History extends BaseService { // 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。 const parseDSL = getEditorConfig('parseDSL'); - this.state.pageSteps = deserializeStacks(snapshot.pageSteps, parseDSL); - this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState, parseDSL); - this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState, parseDSL); - // initial 基线作为页面栈 index 0 的 step 随 pageSteps 一并还原,无需单独恢复。 - this.state.pageId = snapshot.pageId; - this.setCanUndoRedo(); + const steps: HistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} }; + Object.entries(snapshot.steps).forEach(([stepType, bucket]) => { + steps[stepType] = deserializeStacks(bucket, parseDSL); + }); + this.state.steps = steps; + // initial 基线作为各栈 index 0 的 step 随 steps 一并还原,无需单独恢复;活动页由 editorService 维护。 this.emit('restore-from-indexed-db', snapshot); return snapshot; } /** - * 取出当前活动页的历史步骤平铺列表(包含已应用 + 已撤销)。 - * 列表按时间正序,最早一步在最前面。 - * 通常 UI 应使用 `getPageHistoryGroups` 取已合并分组的版本;本方法仅为兼容/调试保留。 + * 取出指定历史类型(页面 / 代码块 / 数据源 / 扩展类型)某个栈的平铺步骤列表(含 applied 标记)。 + * 统一入口,替代旧的 `getPageStepList` / `getCodeBlockStepList` / `getDataSourceStepList`。 + * + * 列表按时间正序,最早一步在最前面;`applied` 表示该步骤处于栈游标之前(已应用)。 + * 供 revert / goto 等按 index 索引步骤的场景使用。通常 UI 应使用 + * {@link getHistoryGroups} 取已合并分组的版本;本方法仅为兼容/调试保留。 + * + * @param stepType 历史类型 + * @param id 目标栈 id(page 为 pageId,其余类型为对应资源 id);缺省 / 无效时返回空数组 */ - public getPageStepList(pageId?: Id): PageHistoryStepEntry[] { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return []; - const undoRedo = this.state.pageSteps[targetPageId]; - if (!undoRedo) return []; - const list = undoRedo.getElementList(); - const cursor = undoRedo.getCursor(); - return list.map((step, index) => ({ - step, - index, - applied: index < cursor, - })); - } - - /** - * 取出当前活动页的历史栈,按"目标节点"做相邻合并: - * - 连续修改同一节点(单节点 update)的多步合并为一个 group,组内可展开查看每步; - * - add / remove / 多节点 update 始终独立成组。 - * 用于历史面板的"页面"tab 展示。 - */ - public getPageHistoryGroups(pageId?: Id): PageHistoryGroup[] { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return []; - const undoRedo = this.state.pageSteps[targetPageId]; - if (!undoRedo) return []; - const list = undoRedo.getElementList(); - if (!list.length) return []; - const cursor = undoRedo.getCursor(); - // initial 基线(index 0)不作为普通操作组展示,过滤掉;其余真实 step 的 index 保持不变, - // 以便面板 goto(index+1) / revert(index) 仍直接对应栈内位置。底部「初始」行由 getPageMarker 驱动。 - return mergePageSteps(targetPageId, list, cursor).filter((group) => group.opType !== 'initial'); - } - - /** - * 取出全部代码块的历史栈,按 codeBlockId 分桶展示。 - * 同一栈内每条操作记录独立成组,不做相邻 update 合并。 - */ - public getCodeBlockHistoryGroups(): StackHistoryGroup[] { - const groups: StackHistoryGroup[] = []; - Object.entries(this.state.codeBlockState).forEach(([id, undoRedo]) => { - if (!undoRedo) return; - const list = undoRedo.getElementList(); - if (!list.length) return; - const cursor = undoRedo.getCursor(); - groups.push(...mergeStackSteps('code-block', id, list, cursor)); - }); - return groups; - } - - /** - * 读取指定页面历史栈的当前游标(已应用步骤数量)。不传则取当前活动页。 - * 没有对应栈时返回 0。 - */ - public getPageCursor(pageId?: Id): number { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return 0; - return this.state.pageSteps[targetPageId]?.getCursor() ?? 0; - } - - /** 读取指定代码块历史栈的当前游标。 */ - public getCodeBlockCursor(codeBlockId: Id): number { - return this.state.codeBlockState[codeBlockId]?.getCursor() ?? 0; - } - - /** 读取指定数据源历史栈的当前游标。 */ - public getDataSourceCursor(dataSourceId: Id): number { - return this.state.dataSourceState[dataSourceId]?.getCursor() ?? 0; - } - - /** - * 取出指定代码块历史栈的平铺步骤列表(含 applied 标记)。供 revert 等按 index 索引步骤使用。 - */ - public getCodeBlockStepList(codeBlockId: Id): { step: CodeBlockStepValue; index: number; applied: boolean }[] { - const undoRedo = this.state.codeBlockState[codeBlockId]; + public getStepList(stepType: 'page', id?: Id): HistoryStepEntry[]; + public getStepList(stepType: 'codeBlock', id: Id): HistoryStepEntry[]; + public getStepList(stepType: 'dataSource', id: Id): HistoryStepEntry[]; + public getStepList(stepType: HistoryStepType, id?: Id): HistoryStepEntry[]; + public getStepList(stepType: HistoryStepType, id?: Id): HistoryStepEntry[] { + if (!this.isValidStackId(id)) return []; + const undoRedo = this.state.steps[stepType]?.[id]; if (!undoRedo) return []; const list = undoRedo.getElementList(); const cursor = undoRedo.getCursor(); @@ -588,83 +434,132 @@ class History extends BaseService { } /** - * 取出指定数据源历史栈的平铺步骤列表(含 applied 标记)。供 revert 等按 index 索引步骤使用。 + * 取出指定历史类型的历史分组(页面 / 代码块 / 数据源 / 扩展类型统一入口)。 + * + * 把目标栈的步骤列表按"目标"做相邻合并(连续修改同一目标的 update 合并为一组,组内可展开查看每步; + * add / remove / 多实体 update 始终独立成组),并过滤掉 index 0 的 initial 基线(底部「初始」行由 + * {@link getMarker} 驱动)。各类型行为完全一致,仅 `kind` 与 step 快照类型不同。 + * + * 作用域: + * - 传入 `id`:仅取该 id 对应的单个栈(页面历史按活动页查看,传入 pageId); + * - 缺省 `id`:遍历该类型下全部栈并汇总(代码块 / 数据源按全部资源分桶展示)。 + * + * @param stepType 历史类型,缺省 `page` + * @param id 目标栈 id;缺省表示遍历该类型下全部栈 */ - public getDataSourceStepList(dataSourceId: Id): { step: DataSourceStepValue; index: number; applied: boolean }[] { - const undoRedo = this.state.dataSourceState[dataSourceId]; - if (!undoRedo) return []; - const list = undoRedo.getElementList(); - const cursor = undoRedo.getCursor(); - return list.map((step, index) => ({ step, index, applied: index < cursor })); - } - - /** - * 按历史记录 uuid 在指定页面(默认当前活动页)的栈中查找其索引。 - * 找不到时返回 -1。供「按 uuid 回滚」等需要把 uuid 映射回 index 的场景使用。 - */ - public getPageStepIndexByUuid(uuid: string, pageId?: Id): number { - if (!uuid) return -1; - return this.getPageStepList(pageId).findIndex((entry) => entry.step.uuid === uuid); - } - - /** - * 按历史记录 uuid 在全部代码块栈中查找其所属 codeBlockId 与索引。 - * 找不到时返回 null。 - */ - public findCodeBlockStepLocationByUuid(uuid: string): { id: Id; index: number } | null { - if (!uuid) return null; - for (const id of Object.keys(this.state.codeBlockState)) { - const index = this.getCodeBlockStepList(id).findIndex((entry) => entry.step.uuid === uuid); - if (index >= 0) return { id, index }; - } - return null; - } - - /** - * 按历史记录 uuid 在全部数据源栈中查找其所属 dataSourceId 与索引。 - * 找不到时返回 null。 - */ - public findDataSourceStepLocationByUuid(uuid: string): { id: Id; index: number } | null { - if (!uuid) return null; - for (const id of Object.keys(this.state.dataSourceState)) { - const index = this.getDataSourceStepList(id).findIndex((entry) => entry.step.uuid === uuid); - if (index >= 0) return { id, index }; - } - return null; - } - - /** - * 取出全部数据源的历史栈,按 dataSourceId 分桶展示。同上,每条操作独立成组。 - */ - public getDataSourceHistoryGroups(): StackHistoryGroup[] { - const groups: StackHistoryGroup[] = []; - Object.entries(this.state.dataSourceState).forEach(([id, undoRedo]) => { - if (!undoRedo) return; + public getHistoryGroups(stepType: 'page', id?: Id): HistoryGroup[]; + public getHistoryGroups(stepType: 'codeBlock', id?: Id): HistoryGroup[]; + public getHistoryGroups(stepType: 'dataSource', id?: Id): HistoryGroup[]; + public getHistoryGroups(stepType: HistoryStepType, id?: Id): HistoryGroup[]; + public getHistoryGroups(stepType: HistoryStepType, id?: Id): HistoryGroup[] { + const bucket = this.state.steps[stepType]; + if (!bucket) return []; + const kind = STEP_TYPE_KIND[stepType] ?? stepType; + const collect = (stackId: Id): HistoryGroup[] => { + const undoRedo = bucket[stackId]; + if (!undoRedo) return []; const list = undoRedo.getElementList(); - if (!list.length) return; - const cursor = undoRedo.getCursor(); - groups.push(...mergeStackSteps('data-source', id, list, cursor)); - }); - return groups; + if (!list.length) return []; + // initial 基线(index 0)不作为普通操作组展示,过滤掉;其余真实 step 的 index 保持不变, + // 以便面板 goto(index+1) / revert(index) 仍直接对应栈内位置。 + return mergeSteps(kind, stackId, list, undoRedo.getCursor()).filter((group) => group.opType !== 'initial'); + }; + if (this.isValidStackId(id)) return collect(id); + return Object.keys(bucket).flatMap((stackId) => collect(stackId as Id)); + } + + /** + * 读取指定历史栈的当前游标(已应用步骤数量)。统一入口,替代旧的 + * `getPageCursor` / `getCodeBlockCursor` / `getDataSourceCursor`。 + * - `id` 缺省或非法、或对应栈不存在时返回 0; + * - `stepType` 支持 `page` / `codeBlock` / `dataSource` / 扩展类型。 + */ + public getCursor(stepType: HistoryStepType, id?: Id): number { + if (!this.isValidStackId(id)) return 0; + return this.state.steps[stepType]?.[id]?.getCursor() ?? 0; + } + + /** + * 按历史记录 uuid 在指定历史类型的栈中查找其所属 id 与索引,统一入口,替代旧的 + * `getPageStepIndexByUuid` / `findCodeBlockStepLocationByUuid` / `findDataSourceStepLocationByUuid`。 + * + * - 传入 `id`:仅在该 id 对应的单个栈中查找(如页面历史按活动页查看,传入 pageId); + * - 缺省 `id`:遍历该类型下全部栈查找(代码块 / 数据源等按全部资源分桶的场景)。 + * + * 找不到时返回 null。供「按 uuid 回滚」等需要把 uuid 映射回 (id, index) 的场景使用。 + * + * @param stepType 历史类型 + * @param uuid 目标历史记录的 uuid + * @param id 目标栈 id;缺省表示遍历该类型下全部栈 + */ + public findStepLocationByUuid(stepType: HistoryStepType, uuid: string, id?: Id): { id: Id; index: number } | null { + if (!uuid) return null; + const bucket = this.state.steps[stepType]; + if (!bucket) return null; + + if (this.isValidStackId(id)) { + const index = this.getStepList(stepType, id).findIndex((entry) => entry.step.uuid === uuid); + return index >= 0 ? { id, index } : null; + } + + for (const stackId of Object.keys(bucket)) { + const index = this.getStepList(stepType, stackId as Id).findIndex((entry) => entry.step.uuid === uuid); + if (index >= 0) return { id: stackId as Id, index }; + } + return null; } public usePlugin(options: SyncHookPlugin): void { super.usePlugin(options); } + public emit(eventName: Name, ...args: Param) { + return super.emit(eventName, ...args); + } + /** - * 取出指定页面的栈;不传 pageId 时按当前活动页取。 - * - * 跨页 push 时如果目标页的栈尚不存在(用户从未进入过该页),会按需创建一条空栈, - * 这样切到目标页时 Ctrl+Z 也能撤回该步骤。 + * 取出指定历史类型的栈桶(`Record`);不存在时按需创建空桶, + * 从而支持通过 {@link registerStepType} 或首次 push 动态扩展历史类型。 */ - private getUndoRedo(pageId?: Id) { - const targetPageId = pageId ?? this.state.pageId; - if (!targetPageId) return null; - if (!this.state.pageSteps[targetPageId]) { - this.state.pageSteps[targetPageId] = new UndoRedo(); + private getStepBucket(stepType: string): Record> { + if (!this.state.steps[stepType]) { + this.state.steps[stepType] = {}; } - return this.state.pageSteps[targetPageId]; + return this.state.steps[stepType]; + } + + /** + * 清空单个历史栈并按需重建其 initial 基线(保留原基线的文案 / 来源)。 + */ + private clearStack(stepType: HistoryStepType, id: Id): void { + // 保留原 initial 基线的文案 / 来源(仅清空其后的真实操作记录),无基线时清空成空栈。 + const marker = this.getMarker(stepType, id); + this.getStepBucket(stepType)[id] = new UndoRedo(); + if (marker) { + this.setMarker(stepType, id, { + name: marker.data?.name, + description: marker.historyDescription, + source: marker.source, + }); + } + } + + /** 入栈前补全 step 的 uuid / timestamp(调用方未指定时)。 */ + private fillStepMeta(step: BaseStepValue): void { + if (step.uuid === undefined) step.uuid = guid(); + if (step.timestamp === undefined) step.timestamp = Date.now(); + } + + /** 校验「按 id 分栈」类型(codeBlock / dataSource / 扩展)的 id 是否有效。 */ + private isValidStackId(id?: Id): id is Id { + return id !== undefined && id !== null && `${id}` !== ''; + } + + /** 清空全部历史栈内容(保留已注册的类型键,使扩展类型在 reset 后仍可用)。 */ + private clearAllSteps(): void { + Object.keys(this.state.steps).forEach((stepType) => { + this.state.steps[stepType] = {}; + }); } /** @@ -676,13 +571,6 @@ class History extends BaseService { const resolvedAppId = appId ?? editorService.get('root')?.id; return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName; } - - private setCanUndoRedo(): void { - const undoRedo = this.getUndoRedo(); - this.state.canRedo = undoRedo?.canRedo() || false; - // 初始基线之上才可撤销:cursor 必须高于底线(有 initial 时为 1)。 - this.state.canUndo = undoRedo ? undoRedo.getCursor() > undoFloor(undoRedo) : false; - } } export type HistoryService = History; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 0b94e441..fea62313 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -780,13 +780,18 @@ export interface StepDiffItem { * * 泛型 `T` 为 `diff` 中变化内容的快照类型(页面节点 `MNode` / 代码块 `CodeBlockContent` / 数据源 `DataSourceSchema`)。 */ -export interface BaseStepValue { +export interface BaseStepValue = {}> { /** * 历史记录唯一标识(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 { /** 操作人 */ operator?: string; /** 扩展信息 */ - extra?: Record; + extra?: U; } // #endregion BaseStepValue -// #region StepValue -export interface StepValue extends BaseStepValue { - /** 页面信息 */ - data: { name: string; id: Id }; - /** 操作前选中的节点 ID,用于撤销后恢复选择状态 */ - selectedBefore: Id[]; - /** 操作后选中的节点 ID,用于重做后恢复选择状态 */ - selectedAfter: Id[]; - modifiedNodeIds: Map; +// #region StepExtra +/** + * 历史记录的扩展上下文({@link BaseStepValue.extra})。 + * 内置字段供 `page` 类型在撤销 / 重做时恢复选区与受影响节点;扩展类型可自由附加其它键。 + */ +export interface StepExtra { + /** 操作前选中的节点 ID,用于撤销后恢复选择状态(page 类型) */ + selectedBefore?: Id[]; + /** 操作后选中的节点 ID,用于重做后恢复选择状态(page 类型) */ + selectedAfter?: Id[]; + /** 本次操作涉及的节点 id 集合(page 类型) */ + modifiedNodeIds?: Map; + [key: string]: any; } +// #endregion StepExtra + +// #region StepValue +/** + * 页面节点历史记录条目(`diff` 内容为 {@link MNode})。结构已与代码块 / 数据源统一收敛到 + * {@link BaseStepValue}:关联 id 见 `data.id`,选区等上下文见 `extra`。 + */ +export type StepValue = BaseStepValue; // #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 { - /** 关联的代码块 id */ - id: Id; -} +export type CodeBlockStepValue = BaseStepValue; // #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 { - /** 关联的数据源 id */ - id: Id; -} +export type DataSourceStepValue = BaseStepValue; // #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>; + codeBlock: Record>; + dataSource: Record>; + /** 扩展历史类型:按 id 分组的 UndoRedo 栈。 */ + [stepType: string]: Record>; +} +// #endregion HistorySteps + export interface HistoryState { - pageId?: Id; - pageSteps: Record>; - 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>; + 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>; + stepNames: Record; } // #region PersistedHistoryState @@ -892,14 +925,16 @@ export interface HistoryState { export interface PersistedHistoryState { /** 快照结构版本号,便于后续兼容升级。 */ version: number; - /** 保存时的活动页 id。 */ - pageId?: Id; - /** 各页面历史栈的序列化快照,按 pageId 分组。 */ - pageSteps: Record>; - /** 各代码块历史栈的序列化快照,按 codeBlockId 分组。 */ - codeBlockState: Record>; - /** 各数据源历史栈的序列化快照,按 dataSourceId 分组。 */ - dataSourceState: Record>; + /** + * 全部历史栈的序列化快照,按「类型 -> id」两级分组,与 {@link HistorySteps} 对应。 + * 内置 `page` / `codeBlock` / `dataSource`,并包含业务注册的扩展类型。 + */ + steps: { + page: Record>; + codeBlock: Record>; + dataSource: Record>; + [stepType: string]: Record>; + }; /** 保存时间戳(毫秒)。 */ savedAt: number; } @@ -927,9 +962,9 @@ export interface HistoryPersistOptions { /** * 历史面板用:当前页面的一条历史步骤(包含位置和是否已应用)。 */ -export interface PageHistoryStepEntry { +export interface HistoryStepEntry { /** 步骤内容 */ - 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`; - * - 代码块:`StackHistoryGroup`。 + * 历史面板分组(页面 / 数据源 / 代码块 / 扩展类型统一结构)。 * - * 每条操作记录独立成组,不做相邻合并(与页面历史 {@link PageHistoryGroup} 不同),故 `steps` 恒为单元素。 + * 把指定历史栈的步骤列表按"目标"做相邻合并: + * - 连续修改同一目标(单实体 update,targetId 一致)的多步合并成一组,组内可展开查看每步; + * - add / remove / 多实体 update 始终独立成组(无法明确归属单一目标); + * - targetId 为 undefined 表示"无明确目标",不参与合并。 + * + * 各类型仅 `kind` 与 step 快照类型不同,统一由泛型描述: + * - 页面:`HistoryGroup`,`kind: 'page'`,`id` 为 pageId,`targetId` 为被改节点 id; + * - 数据源:`HistoryGroup`,`kind: 'data-source'`,`id` 为 dataSource.id; + * - 代码块:`HistoryGroup`,`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 { + /** 历史类型标识: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', diff --git a/packages/editor/src/utils/history.ts b/packages/editor/src/utils/history.ts index 7efc2e46..b6b21bd7 100644 --- a/packages/editor/src/utils/history.ts +++ b/packages/editor/src/utils/history.ts @@ -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 = & { id: Id }>( +export const createStackStep = >( 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, '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 = & { 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 & { id: Id } = { + const step: BaseStepValue = { uuid: guid(), - id, + data: { name, id }, opType, diff: [ { @@ -104,7 +102,10 @@ export const createStackStep = & { 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 = (undoRedo?: UndoRed }; /** - * 把单个「按 id 分栈」的历史栈(代码块 / 数据源)拆成若干 group: - * 每条操作记录独立成组,不做相邻 update 合并(与页面历史的合并策略不同)。 + * 把单个历史栈(页面 / 代码块 / 数据源 / 扩展类型)的步骤列表按"目标"做相邻合并: + * - 单实体的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group,组内可展开查看每步; + * - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单实体修改组); + * - 多实体 'update'(如页面批量改属性)也独立成组(无明确单一目标,避免误合并)。 * - * 代码块与数据源除 `kind` 外结构完全一致,统一由本方法处理;`kind` 决定返回的具体分组类型。 + * 各类型行为完全一致,仅 `kind` 与 step 快照类型不同,统一由本方法处理。 */ -export const mergeStackSteps = ( - kind: K, +export const mergeSteps = ( + kind: string, id: Id, - list: S[], + list: T[], cursor: number, -): StackHistoryGroup[] => { - 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[] => { + const groups: HistoryGroup[] = []; + let current: HistoryGroup | 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 = (stacks: Record>, 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): number => { +export const undoFloor = (undoRedo: UndoRedo): number => { return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0; }; diff --git a/packages/editor/tests/unit/layouts/NavMenu.spec.ts b/packages/editor/tests/unit/layouts/NavMenu.spec.ts index 7a512385..44e9f825 100644 --- a/packages/editor/tests/unit/layouts/NavMenu.spec.ts +++ b/packages/editor/tests/unit/layouts/NavMenu.spec.ts @@ -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(), diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts index 1cec8cc1..894f5277 100644 --- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts @@ -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(); }); diff --git a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts index 6ab117f0..8555ee4e 100644 --- a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts @@ -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 => ({ kind: 'page', - pageId: 'p1', + id: 'p1', opType, applied, targetId, diff --git a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts index 69601bcb..41e58b3f 100644 --- a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts @@ -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 => ({ step, index, applied, @@ -187,9 +206,9 @@ describe('describePageStep', () => { describe('describePageGroup', () => { test('historyDescription 取最后一条非空的描述', () => { - const group: PageHistoryGroup = { + const group: HistoryGroup = { 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 = { 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 = { 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 = { 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 = { 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 = { 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, }); diff --git a/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts b/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts index b26689c6..68f464a6 100644 --- a/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/useHistoryRevert.spec.ts @@ -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', diff --git a/packages/editor/tests/unit/services/codeBlock.spec.ts b/packages/editor/tests/unit/services/codeBlock.spec.ts index 67444a2c..8bfe1965 100644 --- a/packages/editor/tests/unit/services/codeBlock.spec.ts +++ b/packages/editor/tests/unit/services/codeBlock.spec.ts @@ -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 () => { diff --git a/packages/editor/tests/unit/services/dataSource.spec.ts b/packages/editor/tests/unit/services/dataSource.spec.ts index 7ae504d1..95c89c3f 100644 --- a/packages/editor/tests/unit/services/dataSource.spec.ts +++ b/packages/editor/tests/unit/services/dataSource.spec.ts @@ -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', () => { diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index f050fbc8..bab1ecbd 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -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); diff --git a/packages/editor/tests/unit/services/history-persist.spec.ts b/packages/editor/tests/unit/services/history-persist.spec.ts index e4a91262..9f466f67 100644 --- a/packages/editor/tests/unit/services/history-persist.spec.ts +++ b/packages/editor/tests/unit/services/history-persist.spec.ts @@ -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(); @@ -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(); diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts index 91c1eeea..63b7c582 100644 --- a/packages/editor/tests/unit/services/history.spec.ts +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -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); }); }); diff --git a/packages/editor/tests/unit/utils/history.spec.ts b/packages/editor/tests/unit/utils/history.spec.ts index 8d155310..bd7f7f8b 100644 --- a/packages/editor/tests/unit/utils/history.spec.ts +++ b/packages/editor/tests/unit/utils/history.spec.ts @@ -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('新名'); }); }); diff --git a/playground/src/pages/Editor.vue b/playground/src/pages/Editor.vue index 44541be2..be34cb3a 100644 --- a/playground/src/pages/Editor.vue +++ b/playground/src/pages/Editor.vue @@ -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();