Compare commits

...

3 Commits

Author SHA1 Message Date
roymondchen
614f12adf3 feat(editor): 支持历史记录持久化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:04:39 +08:00
roymondchen
bddc6f343c feat(editor): 支持按历史记录 uuid 回滚 2026-06-05 19:25:50 +08:00
roymondchen
be3a900e6a fix(editor): 修复历史对比属性配置上下文缺失 2026-06-05 17:27:20 +08:00
29 changed files with 2151 additions and 140 deletions

View File

@ -235,6 +235,86 @@
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
:::
## setCodeDslByIdAndGetHistoryId
- **参数:** 同 [setCodeDslById](#setcodedslbyid)
- **返回:**
- {`Promise<string | null>`} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true` 等)时返回 `null`
- **详情:**
与 [setCodeDslById](#setcodedslbyid) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", {
name: "代码块1",
content: "() => {}",
});
console.log(historyId); // 本次变更对应的历史记录 uuid或 null
```
## setCodeDslByIdSyncAndGetHistoryId
- **参数:** 同 [setCodeDslByIdSync](#setcodedslbyidsync)
- **返回:**
- {`string | null`} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true`、或 `force=false` 跳过等)时返回 `null`
- **详情:**
与 [setCodeDslByIdSync](#setcodedslbyidsync) 行为完全一致(同步),仅把返回值换成本次写入历史记录的 `uuid`
## deleteCodeDslByIdsAndGetHistoryId
- **参数:** 同 [deleteCodeDslByIds](#deletecodedslbyids)
- **返回:**
- {`Promise<string[]>`} 本次写入的全部历史记录 uuid按删除顺序未写入任何历史时返回空数组 `[]`
- **详情:**
与 [deleteCodeDslByIds](#deletecodedslbyids) 行为完全一致。由于一次可删除多个代码块、会产生多条历史记录,因此返回的是 uuid 数组(每条删除记录一个 uuid不存在的 id 不会入历史,也不会出现在返回数组中。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(["code_1", "code_2"]);
console.log(historyIds); // ['xxxx', 'yyyy'],或 []
```
## revertById
- **参数:**
- `{string}` uuid 目标历史记录的 uuid通常由 [setCodeDslByIdAndGetHistoryId](#setcodedslbyidandgethistoryid) 等方法返回)
- **返回:**
- {`Promise<CodeBlockStepValue | null>`} 反向应用后产生的新 step找不到对应 uuid / 该步未应用时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」某条代码块历史步骤类 git revert 语义),语义同按 `(id, index)` 回滚,
仅无需调用方再传 `codeBlockId``index`:内部会按 uuid 在全部代码块栈中定位对应步骤后再回滚。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { name: "代码块1" });
if (historyId) {
await codeBlockService.revertById(historyId);
}
```
## undo
- **参数:**

View File

@ -406,6 +406,78 @@ import { dataSourceService } from "@tmagic/editor";
dataSourceService.remove("ds_123");
```
## addAndGetHistoryId
- **参数:** 同 [add](#add)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true` 等)时返回 `null`
- **详情:**
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
const historyId = dataSourceService.addAndGetHistoryId({
type: "http",
title: "用户信息",
url: "/api/user",
});
console.log(historyId); // 本次新增对应的历史记录 uuid或 null
```
## updateAndGetHistoryId
- **参数:** 同 [update](#update)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid未写入历史时返回 `null`
- **详情:**
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## removeAndGetHistoryId
- **参数:** 同 [remove](#remove)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid删除的 id 不存在或未写入历史时返回 `null`
- **详情:**
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## revertById
- **参数:**
- `{string}` uuid 目标历史记录的 uuid通常由 [addAndGetHistoryId](#addandgethistoryid) 等方法返回)
- **返回:**
- {`DataSourceStepValue` | null} 反向应用后产生的新 step找不到对应 uuid / 该步未应用时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」某条数据源历史步骤类 git revert 语义),语义同按 `(id, index)` 回滚,
仅无需调用方再传 `dataSourceId``index`:内部会按 uuid 在全部数据源栈中定位对应步骤后再回滚。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
const historyId = dataSourceService.addAndGetHistoryId({ type: "http", title: "用户信息" });
if (historyId) {
dataSourceService.revertById(historyId);
}
```
## createId
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是

View File

@ -12,6 +12,29 @@
编辑器内置交互(画布、树面板、配置面板、右键菜单、快捷键等)会自动传入对应的 `historySource`
业务侧程序化调用时建议显式传入(如 `api`),便于历史面板区分来源。
## 历史记录 uuid 与 \*AndGetHistoryId
每条历史记录入栈时都会自动生成一个唯一标识 `uuid`(见 [StepValue](#undo)),可用于精确引用 / 定位某一条历史记录(如埋点、回滚、跨端同步等)。
DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(节点 / 节点集合 / void不会返回 `uuid`。若需要拿到本次写入历史记录的 `uuid`,可改用对应的 `*AndGetHistoryId` 方法:它们与原方法行为完全一致,仅把返回值换成本次写入历史记录的 `uuid``string`)。当本次操作未写入历史(`doNotPushHistory: true`、无实际变更或提前返回)时返回 `null`
| 原方法 | 取 uuid 的方法 | 返回值 |
| --- | --- | --- |
| [add](#add) | [addAndGetHistoryId](#addandgethistoryid) | `Promise<string \| null>` |
| [remove](#remove) | [removeAndGetHistoryId](#removeandgethistoryid) | `Promise<string \| null>` |
| [update](#update) | [updateAndGetHistoryId](#updateandgethistoryid) | `Promise<string \| null>` |
| [moveLayer](#movelayer) | [moveLayerAndGetHistoryId](#movelayerandgethistoryid) | `Promise<string \| null>` |
| [moveToContainer](#movetocontainer) | [moveToContainerAndGetHistoryId](#movetocontainerandgethistoryid) | `Promise<string \| null>` |
| [dragTo](#dragto) | [dragToAndGetHistoryId](#dragtoandgethistoryid) | `Promise<string \| null>` |
[dataSourceService](./dataSourceServiceMethods.md) / [codeBlockService](./codeBlockServiceMethods.md) 也提供了同名约定的 `*AndGetHistoryId` 方法。
拿到 `uuid` 后,可在需要时按 uuid「回滚」对应的历史记录类 git revert 语义,详见[历史记录面板](../../guide/advanced/history-list.md))。相比按 index 回滚uuid 不会随栈内步骤增删而变化,更适合业务侧持有引用后再回滚:
- 页面:[editorService.revertPageStepById(uuid)](#revertpagestepbyid)
- 数据源:[dataSourceService.revertById(uuid)](./dataSourceServiceMethods.md#revertbyid)
- 代码块:[codeBlockService.revertById(uuid)](./codeBlockServiceMethods.md#revertbyid)
::: details 查看 HistoryOpOptions / DslOpOptions / HistoryOpSource 类型定义
<<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts}
@ -710,6 +733,115 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史
## addAndGetHistoryId
- **参数:** 同 [add](#add)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid)
- **示例:**
```js
import { editorService } from "@tmagic/editor";
const historyId = await editorService.addAndGetHistoryId(
{ type: "text", text: "hello" },
parent,
{ historySource: "api" },
);
console.log(historyId); // 本次新增对应的历史记录 uuid或 null
```
## removeAndGetHistoryId
- **参数:** 同 [remove](#remove)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## updateAndGetHistoryId
- **参数:** 同 [update](#update)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## moveLayerAndGetHistoryId
- **参数:** 同 [moveLayer](#movelayer)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [moveLayer](#movelayer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## moveToContainerAndGetHistoryId
- **参数:** 同 [moveToContainer](#movetocontainer)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [moveToContainer](#movetocontainer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## dragToAndGetHistoryId
- **参数:** 同 [dragTo](#dragto)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [dragTo](#dragto) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## revertPageStepById
- **参数:**
- `{string}` uuid 目标历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid)(通常由 `*AndGetHistoryId` 方法返回)
- **返回:**
- {Promise<`StepValue` | null>} 反向应用后产生的新 step找不到对应 uuid / 该步未应用 / 反向失败时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」当前页面的某条历史步骤类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。语义与按 index 回滚一致,仅入参从 index 改为 uuid更适合业务侧持有引用后再回滚。
::: tip
`opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。
:::
- **示例:**
```js
import { editorService } from "@tmagic/editor";
// 执行操作时拿到本次历史记录 uuid
const historyId = await editorService.addAndGetHistoryId({ type: "text", text: "hello" });
// 之后任意时机按 uuid 回滚该步骤
if (historyId) {
await editorService.revertPageStepById(historyId);
}
```
## undo
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是

View File

@ -73,3 +73,36 @@
- 删除触发的 step 中 `newSchema``null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
:::
## mark-saved
- **详情:** 调用 `markSaved` / `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved` 标记「已保存」记录时触发
- **事件回调函数:** `(payload: { kind: 'all' | 'page' | 'code-block' | 'data-source'; id?: Id }) => void`
::: tip
- `markSaved` 触发时 `kind``all`,无 `id`
- 细粒度方法触发时 `kind` 对应类别,`id` 为目标页面 / 代码块 / 数据源 id
:::
## save-to-indexed-db
- **详情:** `saveToIndexedDB` 把历史记录写入本地 IndexedDB 成功时触发
- **事件回调函数:** `(snapshot: PersistedHistoryState) => void`
::: details 查看 PersistedHistoryState 类型定义
<<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts}
<<< @/../packages/editor/src/utils/undo-redo.ts#SerializedUndoRedo{ts}
:::
## restore-from-indexed-db
- **详情:** `restoreFromIndexedDB` 从本地 IndexedDB 读取并重建历史记录成功时触发(找不到记录时不触发)
- **事件回调函数:** `(snapshot: PersistedHistoryState) => void`
::: details 查看 PersistedHistoryState 类型定义
<<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts}
:::

View File

@ -65,6 +65,12 @@
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
`StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。
若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService /
codeBlockService 提供的 `*AndGetHistoryId` 方法,参见
[editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
`pushCodeBlock` / `pushDataSource` 同样会自动写入 `uuid`
:::
## undo
@ -254,6 +260,122 @@
指定数据源当前是否可重做。栈不存在时返回 `false`
## markSaved
- **详情:**
标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标记为已保存(`saved = true`)。
同一栈内任意时刻最多保留一条已保存记录标记前会清除该栈内全部旧标记某个栈处于「全部已撤销」cursor 为 0时不会留下已保存记录从 IndexedDB 恢复时其游标会回到 0。
通常在 DSL 整体落库(保存到后端 / 本地)成功后调用,配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。仅保存了其中一类时请改用更细粒度的 `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved`
调用后会触发 `mark-saved` 事件(`{ kind: 'all' }`)。
## markPageSaved
- **参数:**
- `{Id} pageId` 可选;缺省为当前活动页
- **详情:**
标记指定页面(缺省当前活动页)历史栈的当前记录为已保存,仅影响该页面自己的栈。触发 `mark-saved` 事件(`{ kind: 'page', id }`)。
## markCodeBlockSaved
- **参数:**
- `{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` 仅清空该数据源,缺省清空全部数据源。仅删除撤销/重做记录,不会改动数据源本身。
## saveToIndexedDB
- **参数:**
- `{HistoryPersistOptions} options` 可选
::: details 查看 HistoryPersistOptions / PersistedHistoryState 类型定义
<<< @/../packages/editor/src/type.ts#HistoryPersistOptions{ts}
<<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts}
<<< @/../packages/editor/src/utils/undo-redo.ts#SerializedUndoRedo{ts}
:::
- **返回:**
- `{Promise<PersistedHistoryState>}` 写入成功的快照对象
- **详情:**
把当前内存中的全部历史栈(页面 / 代码块 / 数据源)连同各自游标、容量序列化后写入本地 IndexedDB。
- 最终库名为 `${dbName}-${当前 DSL app id}`,按应用隔离;
- `key` 用于在同一 store 下区分不同记录,缺省为 `default`
- 历史记录里可能包含函数(代码块内容 / 节点事件等),内部使用 `serialize-javascript` 序列化为字符串后写入,恢复时再用 `parseDSL` 还原,因此可安全持久化函数 / `Map` 等;
- 不支持 IndexedDB 的环境(如 SSR会 reject。
写入成功后触发 `save-to-indexed-db` 事件。
::: warning
`beforeunload` / `pagehide` 阶段浏览器不会等待异步 IndexedDB 事务提交,单纯依赖卸载时写入可能丢失最近一次编辑。建议在历史变更时(防抖)即调用本方法持久化,确保刷新后能完整恢复。
:::
## restoreFromIndexedDB
- **参数:**
- `{HistoryPersistOptions} options` 可选
- **返回:**
- `{Promise<PersistedHistoryState | null>}` 找不到记录时返回 `null`
- **详情:**
从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。
- 每个栈都会按 `listMaxSize` 裁剪并还原游标;
- 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐;
- 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 `pageId`
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
成功后触发 `restore-from-indexed-db``change` 事件。
## destroy
- **详情:**

View File

@ -61,6 +61,12 @@ const menu = ref({
- 数据源:`dataSourceService.revert(id, index)`
- 代码块:`codeBlockService.revert(id, index)`
如果业务侧在执行操作时已通过 `*AndGetHistoryId` 拿到了该条记录的 [uuid](/api/editor/editorServiceMethods.md#历史记录-uuid-与-andgethistoryid),也可以直接按 uuid 回滚(无需再关心 index / id且 uuid 不会随栈内步骤增删而变化):
- 页面:`editorService.revertPageStepById(uuid)`
- 数据源:`dataSourceService.revertById(uuid)`
- 代码块:`codeBlockService.revertById(uuid)`
### 4. 差异对比
在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:

View File

@ -180,7 +180,9 @@ const defaultLoadConfig = async (): Promise<FormConfig> => {
if (!props.type) {
return [];
}
return removeStyleDisplayConfig(await propsService.getPropsConfig(props.type));
return removeStyleDisplayConfig(
await propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
);
}
case 'data-source': {
return dataSourceService.getFormConfig(props.type || 'base');

View File

@ -20,10 +20,11 @@
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s: any) => ({
group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
saved: s.step.saved,
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true),
@ -55,11 +56,12 @@
</div>
</template>
<script lang="ts" setup>
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import type { BaseStepValue } from '@editor/type';
import type { HistoryBucketGroup } from './composables';
import { formatHistoryFullTime, formatHistoryTime, groupSource, groupTimestamp } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
@ -82,20 +84,15 @@ const props = withDefaults(
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false 关闭。 */
showInitial?: boolean;
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
groups: {
applied: boolean;
isCurrent?: boolean;
opType: HistoryOpType;
steps: { index: number; applied: boolean; isCurrent?: boolean; step: any }[];
}[];
groups: HistoryBucketGroup<T>[];
/** 组级描述文案生成器,接收一个 group返回展示文本。由父组件按业务类型注入。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本。用于合并组展开后的子步列表。 */
describeStep: (_step: any) => string;
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */
isStepDiffable?: (_step: any) => boolean;
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords。由父组件按业务类型注入不传则已应用即可回滚。 */
isStepRevertable?: (_step: any) => boolean;
isStepRevertable?: (_step: T) => boolean;
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
/** 是否支持「跳转到该记录」(goto)。默认 true。 */

View File

@ -1,32 +1,40 @@
<template>
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`${prefix}-${bucket.id}`"
:title="title"
:bucket-id="bucket.id"
:prefix="prefix"
:groups="bucket.groups"
:describe-group="describeGroup"
:describe-step="describeStep"
:is-step-diffable="isStepDiffable"
:is-step-revertable="isStepRevertable"
:expanded="expanded"
:goto-enabled="gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
<template v-else>
<div class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" :title="`清空${title}的历史记录`" @click="$emit('clear')">清空</span>
</div>
<TMagicScrollbar max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`${prefix}-${bucket.id}`"
:title="title"
:bucket-id="bucket.id"
:prefix="prefix"
:groups="bucket.groups"
:describe-group="describeGroup"
:describe-step="describeStep"
:is-step-diffable="isStepDiffable"
:is-step-revertable="isStepRevertable"
:expanded="expanded"
:goto-enabled="gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
</template>
<script lang="ts" setup>
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { TMagicScrollbar } from '@tmagic/design';
import type { BaseStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import type { HistoryBucketGroup } from './composables';
defineOptions({
name: 'MEditorHistoryListBucketTab',
@ -42,15 +50,15 @@ withDefaults(
* 已按目标 id 聚拢成的 bucket 列表每个 bucket 内部的 groups 已按时间倒序排好
* 空数组时显示空态
*/
buckets: { id: string | number; groups: any[] }[];
buckets: { id: string | number; groups: HistoryBucketGroup<T>[] }[];
/** 组级描述文案生成器,由父组件按业务类型注入。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,由父组件按业务类型注入。 */
describeStep: (_step: any) => string;
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入。 */
isStepDiffable: (_step: any) => boolean;
isStepDiffable: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords。由父组件按业务类型注入不传则已应用即可回滚。 */
isStepRevertable?: (_step: any) => boolean;
isStepRevertable?: (_step: T) => boolean;
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* tab 使用 `${prefix}-${id}-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
@ -76,5 +84,7 @@ defineEmits<{
(_e: 'diff-step', _targetId: string | number, _index: number): void;
/** 透传 Bucket 的 revert-step 事件,携带目标 id 与 step 索引(类 git revert。 */
(_e: 'revert-step', _targetId: string | number, _index: number): void;
/** 用户点击"清空"按钮,请求清空该类(数据源 / 代码块)的全部历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>();
</script>

View File

@ -13,6 +13,8 @@
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span v-if="headSaved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span
v-if="!merged && sourceLabel(source)"
class="m-editor-history-list-item-source"
@ -57,6 +59,7 @@
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
<span v-if="s.saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span
v-if="sourceLabel(s.source)"
class="m-editor-history-list-item-source"
@ -127,6 +130,8 @@ const props = withDefaults(
applied: boolean;
desc: string;
isCurrent?: boolean;
/** 该子步是否为最近一次保存的记录,用于展示「已保存」标记。 */
saved?: boolean;
diffable?: boolean;
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
revertable?: boolean;
@ -213,6 +218,15 @@ const subStepTitle = (s: { isCurrent?: boolean }) => {
return '';
};
/**
* 头部是否展示已保存标记
* - 单步组取该唯一子步的 saved
* - 合并组组内任一子步为已保存即在头部提示具体落在哪一步可展开查看
*/
const headSaved = computed(() =>
props.merged ? props.subSteps.some((s) => s.saved) : Boolean(props.subSteps[0]?.saved),
);
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));

View File

@ -28,6 +28,7 @@
@goto-initial="onPageGotoInitial"
@diff-step="onPageDiff"
@revert-step="onPageRevert"
@clear="onPageClear"
/>
</component>
@ -50,6 +51,7 @@
@goto-initial="onDataSourceGotoInitial"
@diff-step="onDataSourceDiff"
@revert-step="onDataSourceRevert"
@clear="onDataSourceClear"
/>
</component>
@ -72,6 +74,7 @@
@goto-initial="onCodeBlockGotoInitial"
@diff-step="onCodeBlockDiff"
@revert-step="onCodeBlockRevert"
@clear="onCodeBlockClear"
/>
</component>
@ -130,7 +133,14 @@
import { computed, inject, markRaw, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
import {
getDesignConfig,
TMagicButton,
tMagicMessageBox,
TMagicPopover,
TMagicTabs,
TMagicTooltip,
} from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
@ -381,4 +391,60 @@ const onCodeBlockRevert = (id: string | number, index: number) => {
const onDiffDialogClose = () => {
onConfirmRevert.value = undefined;
};
/**
* 清空历史记录入口先弹出二次确认确认后清空对应类别的历史栈
* 仅删除撤销/重做记录不会改动当前 DSL / 数据源 / 代码块本身
* 用户取消confirm reject时静默忽略
*/
const confirmClear = async (message: string): Promise<boolean> => {
try {
await tMagicMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
return true;
// eslint-disable-next-line no-unused-vars
} catch (e) {
return false;
}
};
/**
* 把内存中已清空对应类别后的历史状态重新写回 IndexedDB
* 使本地持久化的那份与内存保持一致连同本地保存的一并删除
* 不支持 IndexedDB 或写入失败时静默忽略内存清空已生效
*/
const syncIndexedDB = async () => {
try {
await historyService.saveToIndexedDB();
// eslint-disable-next-line no-unused-vars
} catch (e) {
// ignore:
}
};
const onPageClear = async () => {
if (
await confirmClear('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearPage();
await syncIndexedDB();
}
};
const onDataSourceClear = async () => {
if (await confirmClear('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) {
historyService.clearDataSource();
await syncIndexedDB();
}
};
const onCodeBlockClear = async () => {
if (await confirmClear('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) {
historyService.clearCodeBlock();
await syncIndexedDB();
}
};
</script>

View File

@ -1,46 +1,52 @@
<template>
<div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px">
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="group in list"
:key="`pg-${group.steps[0]?.index}`"
:group-key="`pg-${group.steps[0]?.index}`"
:applied="group.applied"
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describePageGroup(group)"
:source="groupSource(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied && isPageStepRevertable(s.step),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)"
/>
<!--
<template v-else>
<div class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" title="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
</div>
<TMagicScrollbar max-height="360px">
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="group in list"
:key="`pg-${group.steps[0]?.index}`"
:group-key="`pg-${group.steps[0]?.index}`"
:applied="group.applied"
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describePageGroup(group)"
:source="groupSource(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
saved: s.step.saved,
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied && isPageStepRevertable(s.step),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)"
/>
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
作为"未修改"零点当所有 group 都未 applied 时它即为当前位置
-->
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
</ul>
</TMagicScrollbar>
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
</ul>
</TMagicScrollbar>
</template>
</template>
<script lang="ts" setup>
@ -88,6 +94,8 @@ defineEmits<{
(_e: 'diff-step', _index: number): void;
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
(_e: 'revert-step', _index: number): void;
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>();
/**

View File

@ -4,6 +4,7 @@ import { datetimeFormatter } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
@ -14,6 +15,21 @@ import type {
StepValue,
} from '@editor/type';
/**
* bucket /
* Bucket / BucketTab step T {@link BaseStepValue}
*/
export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
/** 组内最后一步是否已应用 */
applied: boolean;
/** 是否为当前所在的分组 */
isCurrent?: boolean;
/** 该分组的操作类型 */
opType: HistoryOpType;
/** 组内所有步骤 */
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
*
* - / /

View File

@ -69,6 +69,17 @@ class CodeBlock extends BaseService {
paramsColConfig: undefined,
});
/**
* uuid /
* setCodeDslById(Sync)AndGetHistoryId
*/
private lastPushedHistoryId: string | null = null;
/**
* deleteCodeDslByIds uuid
* deleteCodeDslByIds deleteCodeDslByIdsAndGetHistoryId
*/
private lastDeletedHistoryIds: string[] = [];
constructor() {
super([
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
@ -187,13 +198,14 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) {
historyService.pushCodeBlock(id, {
oldContent,
newContent,
changeRecords,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId =
historyService.pushCodeBlock(id, {
oldContent,
newContent,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('addOrUpdate', id, codeDsl[id]);
@ -295,6 +307,8 @@ class CodeBlock extends BaseService {
if (!currentDsl) return;
this.lastDeletedHistoryIds = [];
codeIds.forEach((id) => {
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
@ -302,13 +316,62 @@ class CodeBlock extends BaseService {
delete currentDsl[id];
if (oldContent && !doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription, source: historySource });
const uuid = historyService.pushCodeBlock(id, {
oldContent,
newContent: null,
historyDescription,
source: historySource,
})?.uuid;
if (uuid) this.lastDeletedHistoryIds.push(uuid);
}
this.emit('remove', id);
});
}
// #region AndGetHistoryId
/**
* *AndGetHistoryId
* uuid{@link CodeBlockStepValue.uuid}
* / revert
*
* doNotPushHistory true null
*/
/** 等价于 {@link setCodeDslById},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async setCodeDslByIdAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
options: HistoryOpOptionsWithChangeRecords = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.setCodeDslById(id, codeConfig, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link setCodeDslByIdSync},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public setCodeDslByIdSyncAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
options: HistoryOpOptionsWithChangeRecords = {},
): string | null {
this.lastPushedHistoryId = null;
this.setCodeDslByIdSync(id, codeConfig, force, options);
return this.lastPushedHistoryId;
}
/**
* {@link deleteCodeDslByIds} uuid
*
*/
public async deleteCodeDslByIdsAndGetHistoryId(codeIds: Id[], options: HistoryOpOptions = {}): Promise<string[]> {
this.lastDeletedHistoryIds = [];
await this.deleteCodeDslByIds(codeIds, options);
return [...this.lastDeletedHistoryIds];
}
// #endregion AndGetHistoryId
public setParamsColConfig(config: TableColumnConfig): void {
this.state.paramsColConfig = config;
}
@ -400,6 +463,20 @@ class CodeBlock extends BaseService {
return await this.applyRevertStep(entry.step, description);
}
/**
* uuid {@link revert}
* codeBlockId index uuid{@link CodeBlockStepValue.uuid}
*
*
* @param uuid uuid {@link setCodeDslByIdAndGetHistoryId}
* @returns step uuid / null
*/
public async revertById(uuid: string): Promise<CodeBlockStepValue | null> {
const location = historyService.findCodeBlockStepLocationByUuid(uuid);
if (!location) return null;
return await this.revert(location.id, location.index);
}
/**
* id
* @returns {Id} id

View File

@ -78,6 +78,13 @@ class DataSource extends BaseService {
methods: {},
});
/**
* uuid
* *AndGetHistoryId add / update / remove id
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
constructor() {
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
}
@ -141,12 +148,13 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig);
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, {
oldSchema: null,
newSchema: newConfig,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: null,
newSchema: newConfig,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('add', newConfig);
@ -181,13 +189,14 @@ class DataSource extends BaseService {
dataSources[index] = newConfig;
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('update', newConfig, {
@ -212,17 +221,52 @@ class DataSource extends BaseService {
dataSources.splice(index, 1);
if (oldConfig && !doNotPushHistory) {
historyService.pushDataSource(id, {
oldSchema: cloneDeep(oldConfig),
newSchema: null,
historyDescription,
source: historySource,
});
this.lastPushedHistoryId =
historyService.pushDataSource(id, {
oldSchema: cloneDeep(oldConfig),
newSchema: null,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('remove', id);
}
// #region AndGetHistoryId
/**
* *AndGetHistoryId add / update / remove
* uuid{@link DataSourceStepValue.uuid}
* / revert
*
* doNotPushHistory true null
*/
/** 等价于 {@link add},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public addAndGetHistoryId(config: DataSourceSchema, options: HistoryOpOptions = {}): string | null {
this.lastPushedHistoryId = null;
this.add(config, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link update},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public updateAndGetHistoryId(
config: DataSourceSchema,
options: HistoryOpOptionsWithChangeRecords = {},
): string | null {
this.lastPushedHistoryId = null;
this.update(config, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link remove},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): string | null {
this.lastPushedHistoryId = null;
this.remove(id, options);
return this.lastPushedHistoryId;
}
// #endregion AndGetHistoryId
/**
*
*
@ -303,6 +347,20 @@ class DataSource extends BaseService {
return this.applyRevertStep(entry.step, description);
}
/**
* uuid {@link revert}
* dataSourceId index uuid{@link DataSourceStepValue.uuid}
*
*
* @param uuid uuid {@link addAndGetHistoryId}
* @returns step uuid / null
*/
public revertById(uuid: string): DataSourceStepValue | null {
const location = historyService.findDataSourceStepLocationByUuid(uuid);
if (!location) return null;
return this.revert(location.id, location.index);
}
public createId(): string {
return `ds_${guid()}`;
}

View File

@ -23,7 +23,15 @@ import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions }
import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage';
import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils';
import {
getNodeInfo,
getNodePath,
getValueByKeyPath,
guid,
isPage,
isPageFragment,
setValueByKeyPath,
} from '@tmagic/utils';
import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props';
@ -116,6 +124,12 @@ class Editor extends BaseService {
alwaysMultiSelect: false,
});
private selectionBeforeOp: Id[] | null = null;
/**
* pushOpHistory uuid
* *AndGetHistoryId id
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
constructor() {
super(
@ -1190,6 +1204,86 @@ class Editor extends BaseService {
this.emit('drag-to', { targetIndex, configs, targetParent });
}
// #region AndGetHistoryId
/**
* *AndGetHistoryId add / remove / update ...
* uuid{@link StepValue.uuid}
* / / revert
*
* doNotPushHistory true / null
*/
/** 等价于 {@link add},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async addAndGetHistoryId(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.add(addNode, parent, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link remove},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async removeAndGetHistoryId(
nodeOrNodeList: MNode | MNode[],
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.remove(nodeOrNodeList, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link update},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async updateAndGetHistoryId(
config: MNode | MNode[],
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
} = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.update(config, data);
return this.lastPushedHistoryId;
}
/** 等价于 {@link moveLayer},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async moveLayerAndGetHistoryId(
offset: number | LayerOffset,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.moveLayer(offset, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link moveToContainer},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async moveToContainerAndGetHistoryId(
config: MNode | MNode[],
targetId: Id,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.moveToContainer(config, targetId, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link dragTo},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async dragToAndGetHistoryId(
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.dragTo(config, targetParent, targetIndex, options);
return this.lastPushedHistoryId;
}
// #endregion AndGetHistoryId
/**
*
* @returns
@ -1320,6 +1414,20 @@ class Editor extends BaseService {
return revertedStep;
}
/**
* uuid {@link revertPageStep}
* index uuid{@link StepValue.uuid}uuid
*
*
* @param uuid uuid *AndGetHistoryId
* @returns step uuid / / null
*/
public async revertPageStepById(uuid: string): Promise<StepValue | null> {
const index = historyService.getPageStepIndexByUuid(uuid);
if (index < 0) return null;
return this.revertPageStep(index);
}
/**
*
*
@ -1429,8 +1537,9 @@ class Editor extends BaseService {
historyDescription?: string;
source?: HistoryOpSource;
},
) {
): string | null {
const step: StepValue = {
uuid: guid(),
data: pageData,
opType,
selectedBefore: this.selectionBeforeOp ?? [],
@ -1442,8 +1551,12 @@ class Editor extends BaseService {
if (source) step.source = source;
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
historyService.push(step, pageData.id);
const pushed = historyService.push(step, pageData.id);
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
const historyId = pushed ? step.uuid : null;
this.lastPushedHistoryId = historyId;
this.selectionBeforeOp = null;
return historyId;
}
/**

View File

@ -18,9 +18,11 @@
import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type {
CodeBlockHistoryGroup,
@ -28,14 +30,25 @@ import type {
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryPersistOptions,
HistoryState,
PageHistoryGroup,
PageHistoryStepEntry,
PersistedHistoryState,
StepValue,
} from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { idbGet, idbSet } from '@editor/utils/indexed-db';
import { UndoRedo } from '@editor/utils/undo-redo';
import BaseService from './BaseService';
import editorService from './editor';
/** 历史记录持久化快照的默认存储位置与结构版本。 */
const DEFAULT_DB_NAME = 'tmagic-editor';
const DEFAULT_STORE_NAME = 'history';
const DEFAULT_KEY: IDBValidKey = 'default';
const PERSIST_VERSION = 1;
class History extends BaseService {
/**
@ -195,6 +208,45 @@ class History extends BaseService {
return undefined;
}
/**
* `saved`
* cursor 0 0
*/
private static markStackSaved<S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void {
if (!undoRedo) return;
undoRedo.updateElements((element) => {
element.saved = false;
});
undoRedo.updateCurrentElement((element) => {
element.saved = true;
});
}
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
private static serializeStacks<T>(stacks: Record<Id, UndoRedo<T>>) {
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
Object.entries(stacks).forEach(([id, undoRedo]) => {
if (undoRedo) result[id] = undoRedo.serialize();
});
return result;
}
/**
* `Record<Id, SerializedUndoRedo>` `Record<Id, UndoRedo>`
* `saved === true`
*/
private static deserializeStacks<T extends { saved?: boolean }>(
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
): Record<Id, UndoRedo<T>> {
const result: Record<Id, UndoRedo<T>> = {};
Object.entries(stacks).forEach(([id, serialized]) => {
if (serialized) {
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
}
});
return result;
}
public state = reactive<HistoryState>({
pageSteps: {},
pageId: undefined,
@ -255,6 +307,7 @@ class History extends BaseService {
public push(state: StepValue, pageId?: Id): StepValue | null {
const undoRedo = this.getUndoRedo(pageId);
if (!undoRedo) return null;
if (state.uuid === undefined) state.uuid = guid();
if (state.timestamp === undefined) state.timestamp = Date.now();
undoRedo.pushElement(state);
// 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。
@ -288,6 +341,7 @@ class History extends BaseService {
if (!codeBlockId) return null;
const step: CodeBlockStepValue = {
uuid: guid(),
id: codeBlockId,
oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null,
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
@ -321,6 +375,7 @@ class History extends BaseService {
if (!dataSourceId) return null;
const step: DataSourceStepValue = {
uuid: guid(),
id: dataSourceId,
oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null,
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
@ -413,6 +468,137 @@ class History extends BaseService {
this.removeAllPlugins();
}
/**
*
* / DSL/
*/
public clearPage(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
if (`${targetPageId}` === `${this.state.pageId}`) {
this.setCanUndoRedo();
this.emit('change', null);
}
}
/**
* `dataSourceId`
* /
*/
public clearDataSource(dataSourceId?: Id): void {
if (dataSourceId !== undefined) {
delete this.state.dataSourceState[dataSourceId];
} else {
this.state.dataSourceState = {};
}
}
/**
* `codeBlockId`
* /
*/
public clearCodeBlock(codeBlockId?: Id): void {
if (codeBlockId !== undefined) {
delete this.state.codeBlockState[codeBlockId];
} else {
this.state.codeBlockState = {};
}
}
/**
* DSL / / `saved`
*
* {@link markPageSaved} / {@link markCodeBlockSaved} / {@link markDataSourceSaved}
*/
public markSaved(): void {
Object.values(this.state.pageSteps).forEach(History.markStackSaved);
Object.values(this.state.codeBlockState).forEach(History.markStackSaved);
Object.values(this.state.dataSourceState).forEach(History.markStackSaved);
this.emit('mark-saved', { kind: 'all' });
}
/**
*
* / /
*/
public markPageSaved(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
History.markStackSaved(this.state.pageSteps[targetPageId]);
this.emit('mark-saved', { kind: 'page', id: targetPageId });
}
/** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */
public markCodeBlockSaved(codeBlockId: Id): void {
if (!codeBlockId) return;
History.markStackSaved(this.state.codeBlockState[codeBlockId]);
this.emit('mark-saved', { kind: 'code-block', id: codeBlockId });
}
/** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */
public markDataSourceSaved(dataSourceId: Id): void {
if (!dataSourceId) return;
History.markStackSaved(this.state.dataSourceState[dataSourceId]);
this.emit('mark-saved', { kind: 'data-source', id: dataSourceId });
}
/**
* / / IndexedDB
*
* - UndoRedo undo/redo
* - `key` / store `default`
* - 便 savedAt
* - IndexedDB SSR reject
*/
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
const snapshot: PersistedHistoryState = {
version: PERSIST_VERSION,
pageId: this.state.pageId,
pageSteps: History.serializeStacks(this.state.pageSteps),
codeBlockState: History.serializeStacks(this.state.codeBlockState),
dataSourceState: History.serializeStacks(this.state.dataSourceState),
savedAt: Date.now(),
};
// 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法IndexedDB 的结构化克隆无法写入函数,
// 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
await idbSet(this.resolveDbName(dbName), storeName, key, serialize(snapshot));
this.emit('save-to-indexed-db', snapshot);
return snapshot;
}
/**
* IndexedDB /
*
* - {@link UndoRedo.fromSerialized} undo/redo
* - pageId
* - null
* - IndexedDB SSR reject
*/
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName), storeName, key);
if (!raw) return null;
// 新版以序列化字符串存储(含函数),用 parseDSL 还原;兼容历史上以对象形式存入的旧数据。
const snapshot = (typeof raw === 'string' ? getEditorConfig('parseDSL')(`(${raw})`) : raw) as PersistedHistoryState;
if (!snapshot) return null;
this.state.pageSteps = History.deserializeStacks(snapshot.pageSteps);
this.state.codeBlockState = History.deserializeStacks(snapshot.codeBlockState);
this.state.dataSourceState = History.deserializeStacks(snapshot.dataSourceState);
this.state.pageId = snapshot.pageId;
this.setCanUndoRedo();
this.emit('restore-from-indexed-db', snapshot);
this.emit('change', null);
return snapshot;
}
/**
* +
*
@ -510,6 +696,41 @@ class History extends BaseService {
return list.map((step, index) => ({ step, index, applied: index < cursor }));
}
/**
* uuid
* -1 uuid uuid index 使
*/
public getPageStepIndexByUuid(uuid: string, pageId?: Id): number {
if (!uuid) return -1;
return this.getPageStepList(pageId).findIndex((entry) => entry.step.uuid === uuid);
}
/**
* uuid codeBlockId
* null
*/
public findCodeBlockStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
if (!uuid) return null;
for (const id of Object.keys(this.state.codeBlockState)) {
const index = this.getCodeBlockStepList(id).findIndex((entry) => entry.step.uuid === uuid);
if (index >= 0) return { id, index };
}
return null;
}
/**
* uuid dataSourceId
* null
*/
public findDataSourceStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
if (!uuid) return null;
for (const id of Object.keys(this.state.dataSourceState)) {
const index = this.getDataSourceStepList(id).findIndex((entry) => entry.step.uuid === uuid);
if (index >= 0) return { id, index };
}
return null;
}
/**
* dataSourceId
*/
@ -540,6 +761,15 @@ class History extends BaseService {
return this.state.pageSteps[targetPageId];
}
/**
* dbName DSLroot app id
* app id DSL退 dbName
*/
private resolveDbName(dbName: string): string {
const appId = editorService.get('root')?.id;
return appId ? `${dbName}-${appId}` : dbName;
}
private setCanUndoRedo(): void {
const undoRedo = this.getUndoRedo();
this.state.canRedo = undoRedo?.canRedo() || false;

View File

@ -45,6 +45,28 @@
list-style: none;
}
// 历史列表工具条放置清空等列表级操作右对齐
.m-editor-history-list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 4px 4px;
}
// 清空按钮红色文字按钮强调破坏性操作点击后会二次确认
.m-editor-history-list-clear {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: #f56c6c;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(245, 108, 108, 0.12);
}
}
.m-editor-history-list-item {
display: flex;
align-items: center;
@ -295,6 +317,20 @@
font-weight: 400; // 防止被合并组头部的粗体继承
}
// 已保存徽标绿色实心胶囊标记最近一次保存对应的历史记录 historyService.markSaved 对应
.m-editor-history-list-item-saved {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 8px;
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #67c23a;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.2px;
}
// 合并 N 徽标紫色实心胶囊与合并组卡片色系一致醒目区分单步条目
.m-editor-history-list-item-merge {
flex: 0 0 auto;

View File

@ -56,7 +56,7 @@ import type { PropsService } from './services/props';
import type { StageOverlayService } from './services/stageOverlay';
import type { StorageService } from './services/storage';
import type { UiService } from './services/ui';
import type { UndoRedo } from './utils/undo-redo';
import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
export type EditorSlots = FrameworkSlots &
WorkspaceSlots &
@ -721,8 +721,42 @@ export type HistoryOpSource =
| (string & {});
// #endregion HistoryOpSource
// #region BaseStepValue
/**
* {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue}
*/
export interface BaseStepValue {
/**
* uuid
* / revert
* `id` / / id
*/
uuid: string;
/**
*
* undo/redo / propPath
*/
historyDescription?: string;
/**
* {@link HistoryOpSource}
* / / / / / / / / /
* undo/redo
*/
source?: HistoryOpSource;
/**
*
*/
timestamp?: number;
/**
* DSL / historyService.markSaved
* true IndexedDB
*/
saved?: boolean;
}
// #endregion BaseStepValue
// #region StepValue
export interface StepValue {
export interface StepValue extends BaseStepValue {
/** 页面信息 */
data: { name: string; id: Id };
opType: HistoryOpType;
@ -746,21 +780,6 @@ export interface StepValue {
* / 退
*/
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[];
/**
*
* undo/redo / propPath
*/
historyDescription?: string;
/**
* {@link HistoryOpSource}
* / / / / / / / / /
* undo/redo
*/
source?: HistoryOpSource;
/**
* historyService.push
*/
timestamp?: number;
}
// #endregion StepValue
@ -771,7 +790,7 @@ export interface StepValue {
* - oldContent / newContent
* - newContent = nulloldContent =
*/
export interface CodeBlockStepValue {
export interface CodeBlockStepValue extends BaseStepValue {
/** 关联的代码块 id */
id: Id;
/** 变更前的代码块内容,新增时为 null */
@ -783,12 +802,6 @@ export interface CodeBlockStepValue {
* 退/ changeRecords
*/
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
source?: HistoryOpSource;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion CodeBlockStepValue
@ -799,7 +812,7 @@ export interface CodeBlockStepValue {
* - oldSchema / newSchema schema
* - newSchema = nulloldSchema = schema
*/
export interface DataSourceStepValue {
export interface DataSourceStepValue extends BaseStepValue {
/** 关联的数据源 id */
id: Id;
/** 变更前的数据源 schema新增时为 null */
@ -811,12 +824,6 @@ export interface DataSourceStepValue {
* 退 schema / changeRecords
*/
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
source?: HistoryOpSource;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion DataSourceStepValue
@ -837,6 +844,39 @@ export interface HistoryState {
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
}
// #region PersistedHistoryState
/**
* historyService.saveToIndexedDB IndexedDB
* historyService.restoreFromIndexedDB UndoRedo
*/
export interface PersistedHistoryState {
/** 快照结构版本号,便于后续兼容升级。 */
version: number;
/** 保存时的活动页 id。 */
pageId?: Id;
/** 各页面历史栈的序列化快照,按 pageId 分组。 */
pageSteps: Record<Id, SerializedUndoRedo<StepValue>>;
/** 各代码块历史栈的序列化快照,按 codeBlockId 分组。 */
codeBlockState: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
/** 各数据源历史栈的序列化快照,按 dataSourceId 分组。 */
dataSourceState: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
/** 保存时间戳(毫秒)。 */
savedAt: number;
}
// #endregion PersistedHistoryState
// #region HistoryPersistOptions
/** historyService 持久化相关 API 的可选配置。 */
export interface HistoryPersistOptions {
/** IndexedDB 数据库名,默认 `tmagic-editor`(最终库名会拼上当前 DSL app id。 */
dbName?: string;
/** objectStore 名,默认 `history`。 */
storeName?: string;
/** 记录 key用于区分不同活动页 / 项目,默认 `default`。 */
key?: IDBValidKey;
}
// #endregion HistoryPersistOptions
// #region HistoryListEntry
/**
*

View File

@ -27,5 +27,6 @@ export * from './dep/idle-task';
export * from './scroll-viewer';
export * from './tree';
export * from './undo-redo';
export * from './indexed-db';
export * from './const';
export { default as loadMonaco } from './monaco-editor';

View File

@ -0,0 +1,122 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* IndexedDB Promise KV
* IndexedDB SSR / reject
*/
/** 是否处于支持 IndexedDB 的环境。 */
export const isIndexedDBSupported = (): boolean => typeof indexedDB !== 'undefined' && indexedDB !== null;
/**
* objectStore
*
* objectStore `onupgradeneeded`
* store storeName
*/
export const openIndexedDB = (dbName: string, storeName: string): Promise<IDBDatabase> =>
new Promise((resolve, reject) => {
if (!isIndexedDBSupported()) {
reject(new Error('当前环境不支持 IndexedDB'));
return;
}
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
};
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
if (db.objectStoreNames.contains(storeName)) {
resolve(db);
return;
}
// store 不存在:以更高版本重开,在 onupgradeneeded 中创建。
const nextVersion = db.version + 1;
db.close();
const upgradeRequest = indexedDB.open(dbName, nextVersion);
upgradeRequest.onupgradeneeded = () => {
const upgradeDb = upgradeRequest.result;
if (!upgradeDb.objectStoreNames.contains(storeName)) {
upgradeDb.createObjectStore(storeName);
}
};
upgradeRequest.onerror = () => reject(upgradeRequest.error);
upgradeRequest.onsuccess = () => resolve(upgradeRequest.result);
};
});
/** 写入覆盖一条记录。value 通过结构化克隆存储,支持 Map / Set 等结构。 */
export const idbSet = async (dbName: string, storeName: string, key: IDBValidKey, value: unknown): Promise<void> => {
const db = await openIndexedDB(dbName, storeName);
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(value, key);
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error);
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
};
/** 读取一条记录,不存在时返回 undefined。 */
export const idbGet = async <T = unknown>(
dbName: string,
storeName: string,
key: IDBValidKey,
): Promise<T | undefined> => {
const db = await openIndexedDB(dbName, storeName);
try {
return await new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const request = tx.objectStore(storeName).get(key);
request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error);
});
} finally {
db.close();
}
};
/** 删除一条记录。 */
export const idbDelete = async (dbName: string, storeName: string, key: IDBValidKey): Promise<void> => {
const db = await openIndexedDB(dbName, storeName);
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(key);
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error);
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
};

View File

@ -18,8 +18,59 @@
import { cloneDeep } from 'lodash-es';
// #region SerializedUndoRedo
/**
* UndoRedo IndexedDB
*/
export interface SerializedUndoRedo<T = any> {
/** 栈内全部元素(按时间正序,索引 0 为最早一步)。 */
elementList: T[];
/** 游标位置(已应用步骤数量)。 */
listCursor: number;
/** 栈容量上限。 */
listMaxSize: number;
}
// #endregion SerializedUndoRedo
// #region UndoRedo
export class UndoRedo<T = any> {
/**
* {@link UndoRedo.serialize} UndoRedo
* [0, length]
*
* @param options.isSavedStep
* 退
*/
public static fromSerialized<T = any>(
data: SerializedUndoRedo<T>,
options: { isSavedStep?: (element: T) => boolean } = {},
): UndoRedo<T> {
const undoRedo = new UndoRedo<T>(data.listMaxSize);
const list = Array.isArray(data.elementList) ? data.elementList.map((item) => cloneDeep(item)) : [];
let cursor = Number.isFinite(data.listCursor) ? data.listCursor : list.length;
// 本地数据同样遵循容量上限:超出时裁掉最旧的记录(与 pushElement 的 shift 行为一致),并同步回退游标。
const overflow = list.length - undoRedo.listMaxSize;
if (overflow > 0) {
list.splice(0, overflow);
cursor -= overflow;
}
// 若指定了「已保存」谓词,则把游标移动到最近一条已保存记录之后;在裁剪后的 list 上查找以保证索引正确。
if (options.isSavedStep) {
for (let i = list.length - 1; i >= 0; i--) {
if (options.isSavedStep(list[i])) {
cursor = i + 1;
break;
}
}
}
undoRedo.elementList = list;
undoRedo.listCursor = Math.max(0, Math.min(cursor, list.length));
return undoRedo;
}
private elementList: T[];
private listCursor: number;
private listMaxSize: number;
@ -31,6 +82,18 @@ export class UndoRedo<T = any> {
this.listMaxSize = listMaxSize > minListMaxSize ? listMaxSize : minListMaxSize;
}
/**
*
* {@link UndoRedo.fromSerialized} /
*/
public serialize(): SerializedUndoRedo<T> {
return {
elementList: this.elementList.map((item) => cloneDeep(item)),
listCursor: this.listCursor,
listMaxSize: this.listMaxSize,
};
}
public pushElement(element: T): void {
// 新元素进来时,把游标之外的元素全部丢弃,并把新元素放进来
this.elementList.splice(this.listCursor, this.elementList.length - this.listCursor, cloneDeep(element));
@ -76,6 +139,20 @@ export class UndoRedo<T = any> {
return cloneDeep(this.elementList[this.listCursor - 1]);
}
/**
* cursor - 1cursor 0
*
*/
public updateCurrentElement(updater: (element: T) => void): void {
if (this.listCursor < 1) return;
updater(this.elementList[this.listCursor - 1]);
}
/** 对栈内全部元素做就地更新。用于批量清理元数据(如清空所有元素的已保存标记)。 */
public updateElements(updater: (element: T, index: number) => void): void {
this.elementList.forEach(updater);
}
/**
* 0
*

View File

@ -231,6 +231,98 @@ describe('CodeBlockService - 历史记录接入', () => {
});
});
describe('CodeBlockService - *AndGetHistoryId', () => {
const lastStepUuid = (id: string) => {
const list = historyService.getCodeBlockStepList(id);
return list[list.length - 1]?.step.uuid;
};
test('setCodeDslByIdSyncAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid('a'));
// 与默认行为一致:内容仍被写入
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
});
test('setCodeDslByIdSyncAndGetHistoryId - force=false 已存在时返回 null', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'NEW' } as any, false);
expect(historyId).toBeNull();
});
test('setCodeDslByIdSyncAndGetHistoryId - doNotPushHistory 时返回 null', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any, true, {
doNotPushHistory: true,
});
expect(historyId).toBeNull();
});
test('setCodeDslByIdAndGetHistoryIdasync返回本次写入历史记录的 uuid', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId('a', { name: 'A' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid('a'));
});
test('deleteCodeDslByIdsAndGetHistoryId 返回每条删除记录的 uuid 数组', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'b']);
expect(Array.isArray(historyIds)).toBe(true);
expect(historyIds).toHaveLength(2);
expect(historyIds[0]).toBe(lastStepUuid('a'));
expect(historyIds[1]).toBe(lastStepUuid('b'));
});
test('deleteCodeDslByIdsAndGetHistoryId - 不存在的 id 不计入返回数组', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'ghost']);
expect(historyIds).toHaveLength(1);
expect(historyIds[0]).toBe(lastStepUuid('a'));
});
test('deleteCodeDslByIdsAndGetHistoryId - 全部不存在时返回空数组', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['ghost']);
expect(historyIds).toEqual([]);
});
});
describe('CodeBlockService - revertById', () => {
test('通过 uuid 回滚新增(删除代码块内容)', async () => {
await codeBlockService.setCodeDsl({} as any);
const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
expect(typeof uuid).toBe('string');
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
const reverted = await codeBlockService.revertById(uuid!);
expect(reverted).not.toBeNull();
expect(codeBlockService.getCodeContentById('a')).toBeNull();
});
test('按 uuid 能定位到对应 (id, index)', async () => {
await codeBlockService.setCodeDsl({} as any);
const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
const location = historyService.findCodeBlockStepLocationByUuid(uuid!);
expect(location).toEqual({ id: 'a', index: 0 });
});
test('找不到 uuid 时返回 null', async () => {
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
await expect(codeBlockService.revertById('not-exist')).resolves.toBeNull();
await expect(codeBlockService.revertById('')).resolves.toBeNull();
});
});
describe('CodeBlockService - undo / redo', () => {
test('undo / redo - 新增场景:撤销=删除,重做=再写回', async () => {
await codeBlockService.setCodeDsl({} as any);

View File

@ -187,6 +187,79 @@ describe('DataSource service - 历史记录接入', () => {
});
});
describe('DataSource service - *AndGetHistoryId', () => {
const lastStepUuid = (id: string) => {
const list = historyService.getDataSourceStepList(id);
return list[list.length - 1]?.step.uuid;
};
test('addAndGetHistoryId 返回本次写入历史记录的 uuid', () => {
const ds = dataSource.add({ id: 'temp', title: 'a', type: 'base' } as any);
historyService.reset();
const historyId = dataSource.addAndGetHistoryId({ id: 'ds_new', title: 'a', type: 'base' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid('ds_new'));
// 与默认 add 行为一致:仍会写入数据源
expect(dataSource.getDataSourceById('ds_new')).toBeDefined();
expect(ds).toBeDefined();
});
test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', () => {
const historyId = dataSource.addAndGetHistoryId({ id: 'ds_x', title: 'a', type: 'base' } as any, {
doNotPushHistory: true,
});
expect(historyId).toBeNull();
});
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
const historyId = dataSource.updateAndGetHistoryId({ ...created, title: 'b' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid(created.id!));
});
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid不存在的 id 返回 null', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
const historyId = dataSource.removeAndGetHistoryId(created.id!);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid(created.id!));
expect(dataSource.removeAndGetHistoryId('ghost')).toBeNull();
});
});
describe('DataSource service - revertById', () => {
test('通过 uuid 回滚 add移除数据源', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid;
expect(typeof uuid).toBe('string');
expect(dataSource.getDataSourceById(created.id!)).toBeDefined();
const reverted = dataSource.revertById(uuid!);
expect(reverted).not.toBeNull();
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
});
test('通过 uuid 回滚等价于按 (id, index) 回滚', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid;
const location = historyService.findDataSourceStepLocationByUuid(uuid!);
expect(location).toEqual({ id: created.id, index: 0 });
});
test('找不到 uuid 时返回 null', () => {
dataSource.add({ title: 'a', type: 'base' } as any);
expect(dataSource.revertById('not-exist')).toBeNull();
expect(dataSource.revertById('')).toBeNull();
});
});
describe('DataSource service - undo / redo', () => {
test('undo / redo - 新增场景:撤销=移除,重做=再添加', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);

View File

@ -711,3 +711,99 @@ describe('undo redo', () => {
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270);
});
});
describe('*AndGetHistoryId', () => {
const lastStepUuid = () => {
const list = historyService.getPageStepList();
return list[list.length - 1]?.step.uuid;
};
test('addAndGetHistoryId 返回本次写入历史记录的 uuid且与栈顶 step 一致', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.addAndGetHistoryId({ type: 'text' });
expect(typeof historyId).toBe('string');
expect(historyId).toBeTruthy();
expect(historyId).toBe(lastStepUuid());
});
test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.addAndGetHistoryId({ type: 'text' }, null, { doNotPushHistory: true });
expect(historyId).toBeNull();
});
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.updateAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text', text: 'x' });
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid());
});
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.removeAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text' });
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid());
});
test('moveLayerAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.NODE_ID);
const historyId = await editorService.moveLayerAndGetHistoryId(1);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid());
});
});
describe('revertPageStepById', () => {
test('通过 uuid 回滚 add 步骤(删除被新增节点)', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const uuid = await editorService.addAndGetHistoryId({ type: 'text' });
expect(typeof uuid).toBe('string');
const addedStep = historyService.getPageStepList().find((e) => e.step.uuid === uuid)!.step;
const addedId = addedStep.nodes![0].id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
const reverted = await editorService.revertPageStepById(uuid!);
expect(reverted).not.toBeNull();
// 回滚git revert 语义)会把被新增的节点删掉
expect(editorService.getNodeById(addedId)).toBeNull();
});
test('与按 index 回滚结果一致', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const uuid = await editorService.addAndGetHistoryId({ type: 'text' });
const index = historyService.getPageStepIndexByUuid(uuid!);
expect(index).toBeGreaterThanOrEqual(0);
});
test('找不到 uuid 时返回 null', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
expect(await editorService.revertPageStepById('not-exist')).toBeNull();
expect(await editorService.revertPageStepById('')).toBeNull();
});
});

View File

@ -0,0 +1,240 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import history from '@editor/services/history';
import { setEditorConfig } from '@editor/utils/config';
import * as indexedDb from '@editor/utils/indexed-db';
// 用内存实现 mock 掉 IndexedDB 读写工具,避免依赖真实 IndexedDBhappy-dom 不提供)。
vi.mock('@editor/utils/indexed-db', () => {
const store = new Map<string, any>();
const k = (db: string, s: string, key: any) => `${db}__${s}__${String(key)}`;
return {
isIndexedDBSupported: () => true,
openIndexedDB: vi.fn(),
idbSet: vi.fn(async (db: string, s: string, key: any, value: any) => {
store.set(k(db, s, key), value);
}),
idbGet: vi.fn(async (db: string, s: string, key: any) => store.get(k(db, s, key))),
idbDelete: vi.fn(async (db: string, s: string, key: any) => {
store.delete(k(db, s, key));
}),
__store: store,
};
});
beforeAll(() => {
// restoreFromIndexedDB 通过 parseDSL 还原序列化字符串(默认实现即 eval
// eslint-disable-next-line no-eval
setEditorConfig({ parseDSL: (dsl: string) => eval(dsl) } as any);
});
beforeEach(() => {
(indexedDb as any).__store.clear();
});
afterEach(() => {
history.reset();
});
const pageStep = (id = 'p1') => ({ data: { id, name: '' }, modifiedNodeIds: new Map() }) as any;
describe('history service - markSaved', () => {
test('markSaved 标记页面 / 代码块 / 数据源各栈的当前记录', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.markSaved();
expect((history.state.pageSteps as any).p1.getCurrentElement().saved).toBe(true);
expect((history.state.codeBlockState as any).code_1.getCurrentElement().saved).toBe(true);
expect((history.state.dataSourceState as any).ds_1.getCurrentElement().saved).toBe(true);
});
test('markSaved 派发 mark-saved 事件并带 kind=all', () => {
const fn = vi.fn();
history.on('mark-saved', fn);
history.markSaved();
expect(fn).toHaveBeenCalledWith({ kind: 'all' });
history.off('mark-saved', fn);
});
test('同一栈最多保留一条 saved再次标记会清除旧标记', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.markPageSaved();
history.push(pageStep());
history.markPageSaved();
const list = (history.state.pageSteps as any).p1.getElementList();
expect(list.filter((s: any) => s.saved)).toHaveLength(1);
// 最新一条才是 saved
expect(list[list.length - 1].saved).toBe(true);
expect(list[0].saved).toBeFalsy();
});
test('markPageSaved / markCodeBlockSaved / markDataSourceSaved 仅影响对应栈', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.markCodeBlockSaved('code_1');
expect((history.state.codeBlockState as any).code_1.getCurrentElement().saved).toBe(true);
// 页面栈未被标记
expect((history.state.pageSteps as any).p1.getCurrentElement().saved).toBeFalsy();
});
});
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);
history.clearPage();
expect((history.state.pageSteps as any).p1.getLength()).toBe(0);
expect(history.state.canUndo).toBe(false);
expect(history.state.canRedo).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 });
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);
});
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 });
history.clearDataSource('ds_1');
expect((history.state.dataSourceState as any).ds_1).toBeUndefined();
expect((history.state.dataSourceState as any).ds_2).toBeDefined();
history.clearDataSource();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
});
});
describe('history service - IndexedDB 持久化', () => {
test('saveToIndexedDB 以序列化字符串写入并返回快照对象', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
const snapshot = await history.saveToIndexedDB();
expect(snapshot.version).toBe(1);
expect(snapshot.pageId).toBe('p1');
// 实际写入 IndexedDB 的是字符串serialize-javascript 结果)
expect(indexedDb.idbSet).toHaveBeenCalled();
const written = (indexedDb.idbSet as any).mock.calls[0][3];
expect(typeof written).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 });
await history.saveToIndexedDB();
history.reset();
expect(Object.keys(history.state.pageSteps)).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();
});
test('restoreFromIndexedDB 把游标恢复到最近一个已保存记录', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push(pageStep());
history.markPageSaved(); // 标记 index 1cursor=2
history.push(pageStep()); // cursor=3saved 仍在 index 1
await history.saveToIndexedDB();
history.reset();
await history.restoreFromIndexedDB();
// 恢复后游标定位到已保存记录之后index 1 -> cursor 2
expect(history.getPageCursor('p1')).toBe(2);
});
test('restoreFromIndexedDB 能还原内容中的函数serialize + parseDSL 往返)', async () => {
history.pushCodeBlock('code_1', {
oldContent: null,
newContent: {
name: 'A',
code() {
return 42;
},
} as any,
});
await history.saveToIndexedDB();
history.reset();
await history.restoreFromIndexedDB();
const current = (history.state.codeBlockState as any).code_1.getCurrentElement();
expect(typeof current.newContent.code).toBe('function');
expect(current.newContent.code()).toBe(42);
});
test('restoreFromIndexedDB 找不到记录时返回 null 且不改动当前状态', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
const restored = await history.restoreFromIndexedDB();
expect(restored).toBeNull();
// 当前状态保持不变
expect((history.state.pageSteps as any).p1.getLength()).toBe(1);
});
test('saveToIndexedDB 派发 save-to-indexed-db、restoreFromIndexedDB 派发 restore-from-indexed-db', async () => {
const saveFn = vi.fn();
const restoreFn = vi.fn();
history.on('save-to-indexed-db', saveFn);
history.on('restore-from-indexed-db', restoreFn);
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
await history.saveToIndexedDB();
await history.restoreFromIndexedDB();
expect(saveFn).toHaveBeenCalledTimes(1);
expect(restoreFn).toHaveBeenCalledTimes(1);
history.off('save-to-indexed-db', saveFn);
history.off('restore-from-indexed-db', restoreFn);
});
});

View File

@ -103,6 +103,32 @@ describe('history service', () => {
} as any);
expect(step?.timestamp).toBe(123456);
});
test('push 未带 uuid 时自动生成 uuid', () => {
history.changePage({ id: 'p1' } as any);
const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('push 已带 uuid 时保留调用方指定的值', () => {
history.changePage({ id: 'p1' } as any);
const step = history.push({
uuid: 'my-uuid',
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
} as any);
expect(step?.uuid).toBe('my-uuid');
});
test('push 为每条记录生成不同的 uuid', () => {
history.changePage({ id: 'p1' } as any);
const s1 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
const s2 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect(s1?.uuid).toBeTruthy();
expect(s2?.uuid).toBeTruthy();
expect(s1?.uuid).not.toBe(s2?.uuid);
});
});
describe('history service - codeBlock', () => {
@ -138,6 +164,12 @@ describe('history service - codeBlock', () => {
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushCodeBlock 自动生成 uuid', () => {
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_1', {
@ -218,6 +250,12 @@ describe('history service - dataSource', () => {
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushDataSource 自动生成 uuid', () => {
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
history.pushDataSource('ds_1', {
oldSchema: null,

View File

@ -161,3 +161,120 @@ describe('list max size', () => {
expect(small.canUndo()).toBe(false);
});
});
describe('updateCurrentElement / updateElements', () => {
test('updateCurrentElement 就地更新当前游标元素', () => {
const undoRedo = new UndoRedo();
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.updateCurrentElement((el: any) => {
el.saved = true;
});
expect(undoRedo.getCurrentElement()).toEqual({ a: 2, saved: true });
// 撤销后当前元素是更早的那条,不应被标记
expect(undoRedo.undo()).toEqual({ a: 2, saved: true });
expect(undoRedo.getCurrentElement()).toEqual({ a: 1 });
});
test('updateCurrentElement 在 cursor 为 0 时不做任何操作', () => {
const undoRedo = new UndoRedo();
undoRedo.pushElement({ a: 1 });
undoRedo.undo();
let called = false;
undoRedo.updateCurrentElement(() => {
called = true;
});
expect(called).toBe(false);
});
test('updateElements 遍历就地更新全部元素', () => {
const undoRedo = new UndoRedo();
undoRedo.pushElement({ a: 1, saved: true });
undoRedo.pushElement({ a: 2 });
undoRedo.updateElements((el: any) => {
el.saved = false;
});
const list = undoRedo.getElementList() as any[];
expect(list.every((el) => el.saved === false)).toBe(true);
});
});
describe('serialize / fromSerialized', () => {
test('serialize 导出快照并 fromSerialized 完整还原(含游标)', () => {
const undoRedo = new UndoRedo(50);
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.pushElement({ a: 3 });
undoRedo.undo(); // cursor = 2
const data = undoRedo.serialize();
expect(data.elementList).toHaveLength(3);
expect(data.listCursor).toBe(2);
expect(data.listMaxSize).toBe(50);
const restored = UndoRedo.fromSerialized(data);
expect(restored.getCursor()).toBe(2);
expect(restored.getLength()).toBe(3);
expect(restored.getCurrentElement()).toEqual({ a: 2 });
expect(restored.canRedo()).toBe(true);
expect(restored.redo()).toEqual({ a: 3 });
});
test('serialize 为深克隆,修改原栈不影响快照', () => {
const undoRedo = new UndoRedo();
const el: any = { a: 1 };
undoRedo.pushElement(el);
const data = undoRedo.serialize();
undoRedo.updateCurrentElement((cur: any) => {
cur.a = 999;
});
expect((data.elementList[0] as any).a).toBe(1);
});
test('fromSerialized 超出 listMaxSize 时裁掉最旧记录并回退游标', () => {
const restored = UndoRedo.fromSerialized({
elementList: [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }],
listCursor: 4,
listMaxSize: 2,
});
expect(restored.getLength()).toBe(2);
// 保留最近两条cursor 同步回退到 2
expect(restored.getElementList()).toEqual([{ a: 3 }, { a: 4 }]);
expect(restored.getCursor()).toBe(2);
});
test('fromSerialized 游标越界时夹紧到 [0, length]', () => {
const restored = UndoRedo.fromSerialized({
elementList: [{ a: 1 }, { a: 2 }],
listCursor: 99,
listMaxSize: 100,
});
expect(restored.getCursor()).toBe(2);
});
test('fromSerialized isSavedStep 把游标定位到最近一条已保存记录之后', () => {
const restored = UndoRedo.fromSerialized<{ a: number; saved?: boolean }>(
{
elementList: [{ a: 1 }, { a: 2, saved: true }, { a: 3 }, { a: 4 }],
listCursor: 4,
listMaxSize: 100,
},
{ isSavedStep: (el) => el.saved === true },
);
// 最近一条已保存记录在 index 1游标应为 2
expect(restored.getCursor()).toBe(2);
expect(restored.getCurrentElement()).toEqual({ a: 2, saved: true });
});
test('fromSerialized isSavedStep 无匹配时退回原游标', () => {
const restored = UndoRedo.fromSerialized<{ a: number; saved?: boolean }>(
{
elementList: [{ a: 1 }, { a: 2 }],
listCursor: 1,
listMaxSize: 100,
},
{ isSavedStep: (el) => el.saved === true },
);
expect(restored.getCursor()).toBe(1);
});
});

View File

@ -47,12 +47,14 @@
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref, shallowRef, toRaw } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, toRaw } from 'vue';
import { debounce } from 'lodash-es';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import type { DatasourceTypeOption } from '@tmagic/editor';
import {
editorService,
historyService,
propsService,
serializeConfig,
TMagicDialog,
@ -96,6 +98,8 @@ const { moveableOptions } = useEditorMoveableOptions(editor);
const save = () => {
localStorage.setItem('magicDSL', serializeConfig(toRaw(value.value)));
editor.value?.editorService.resetModifiedNodeId();
// IndexedDB
historyService.markSaved();
};
const { menu, deviceGroup, iframe, previewVisible } = useEditorMenu(value, save);
@ -133,7 +137,46 @@ propsService.usePlugin({
beforeFillConfig: (config) => [config, '100px'],
});
// IndexedDB DSL app id
// beforeunload / pagehide await IndexedDB
const persistHistory = () => {
historyService.saveToIndexedDB().catch((error) => console.error('持久化历史记录失败', error));
};
// / / IndexedDB
// beforeunload/pagehide
// /
const schedulePersist = debounce(persistHistory, 500);
// IndexedDB undo/redo
const restoreHistory = async () => {
try {
const snapshot = await historyService.restoreFromIndexedDB();
if (!snapshot) return;
const page = editorService.get('page');
if (page) historyService.changePage(page);
} catch (error) {
console.error('恢复历史记录失败', error);
}
};
onMounted(() => {
restoreHistory();
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);
});
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();
});