mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-08 16:32:02 +00:00
feat(editor): 支持历史记录持久化
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bddc6f343c
commit
614f12adf3
@ -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}
|
||||
:::
|
||||
|
||||
@ -260,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
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -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。 */
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}>();
|
||||
|
||||
/**
|
||||
|
||||
@ -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 }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史记录面板共享逻辑:
|
||||
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
|
||||
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';
|
||||
@ -29,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 {
|
||||
/**
|
||||
@ -196,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,
|
||||
@ -417,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出当前活动页的历史步骤平铺列表(包含已应用 + 已撤销)。
|
||||
* 列表按时间正序,最早一步在最前面。
|
||||
@ -579,6 +761,15 @@ class History extends BaseService {
|
||||
return this.state.pageSteps[targetPageId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把基础 dbName 与当前 DSL(root 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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,13 +721,42 @@ export type HistoryOpSource =
|
||||
| (string & {});
|
||||
// #endregion HistoryOpSource
|
||||
|
||||
// #region StepValue
|
||||
export interface StepValue {
|
||||
// #region BaseStepValue
|
||||
/**
|
||||
* 历史记录条目公共字段,被 {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue} 复用。
|
||||
*/
|
||||
export interface BaseStepValue {
|
||||
/**
|
||||
* 历史记录唯一标识(uuid)。在 historyService.push 时自动写入(若调用方未指定),
|
||||
* 历史记录唯一标识(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 extends BaseStepValue {
|
||||
/** 页面信息 */
|
||||
data: { name: string; id: Id };
|
||||
opType: HistoryOpType;
|
||||
@ -751,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
|
||||
|
||||
@ -776,12 +790,7 @@ export interface StepValue {
|
||||
* - 更新:oldContent / newContent 都为对应内容
|
||||
* - 删除:newContent = null,oldContent = 删除前内容
|
||||
*/
|
||||
export interface CodeBlockStepValue {
|
||||
/**
|
||||
* 历史记录唯一标识(uuid),入栈时自动写入,用于精确定位 / 引用某一条历史记录。
|
||||
* 注意与 `id`(关联的代码块 id)区分。
|
||||
*/
|
||||
uuid: string;
|
||||
export interface CodeBlockStepValue extends BaseStepValue {
|
||||
/** 关联的代码块 id */
|
||||
id: Id;
|
||||
/** 变更前的代码块内容,新增时为 null */
|
||||
@ -793,12 +802,6 @@ export interface CodeBlockStepValue {
|
||||
* 缺省才退化为整内容替换。新增/删除场景通常无 changeRecords。
|
||||
*/
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
|
||||
historyDescription?: string;
|
||||
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
|
||||
source?: HistoryOpSource;
|
||||
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
|
||||
timestamp?: number;
|
||||
}
|
||||
// #endregion CodeBlockStepValue
|
||||
|
||||
@ -809,12 +812,7 @@ export interface CodeBlockStepValue {
|
||||
* - 更新:oldSchema / newSchema 都为对应 schema
|
||||
* - 删除:newSchema = null,oldSchema = 删除前 schema
|
||||
*/
|
||||
export interface DataSourceStepValue {
|
||||
/**
|
||||
* 历史记录唯一标识(uuid),入栈时自动写入,用于精确定位 / 引用某一条历史记录。
|
||||
* 注意与 `id`(关联的数据源 id)区分。
|
||||
*/
|
||||
uuid: string;
|
||||
export interface DataSourceStepValue extends BaseStepValue {
|
||||
/** 关联的数据源 id */
|
||||
id: Id;
|
||||
/** 变更前的数据源 schema,新增时为 null */
|
||||
@ -826,12 +824,6 @@ export interface DataSourceStepValue {
|
||||
* 缺省才退化为整 schema 替换。新增/删除场景通常无 changeRecords。
|
||||
*/
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
|
||||
historyDescription?: string;
|
||||
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
|
||||
source?: HistoryOpSource;
|
||||
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
|
||||
timestamp?: number;
|
||||
}
|
||||
// #endregion DataSourceStepValue
|
||||
|
||||
@ -852,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
|
||||
/**
|
||||
* 历史面板用:当前页面的一条历史步骤(包含位置和是否已应用)。
|
||||
|
||||
@ -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';
|
||||
|
||||
122
packages/editor/src/utils/indexed-db.ts
Normal file
122
packages/editor/src/utils/indexed-db.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
@ -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 - 1)做就地更新;cursor 为 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 为最早一步)。
|
||||
* 仅用于历史面板等只读展示场景,不应直接修改返回值。
|
||||
|
||||
240
packages/editor/tests/unit/services/history-persist.spec.ts
Normal file
240
packages/editor/tests/unit/services/history-persist.spec.ts
Normal 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 读写工具,避免依赖真实 IndexedDB(happy-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 1(cursor=2)
|
||||
history.push(pageStep()); // cursor=3,saved 仍在 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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user