feat(editor): 支持页面初始基线与 root 变更历史记录

设置 root 时为各页建立 initial 基线并展示在历史列表底部;编辑期间再次 set root 按页面粒度写入历史,并抽取历史工具函数以支持撤销下限与持久化恢复。
This commit is contained in:
roymondchen 2026-06-11 15:00:11 +08:00
parent c4ec2c5c72
commit 4f284e8d9c
27 changed files with 1067 additions and 428 deletions

View File

@ -72,6 +72,7 @@ export { default as Resizer } from './components/Resizer.vue';
export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue';
export { default as CompareForm } from './components/CompareForm.vue';
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
export { default as FloatingBox } from './components/FloatingBox.vue';
export { default as Tree } from './components/Tree.vue';

View File

@ -55,7 +55,7 @@ export const initServiceState = (
watch(
() => props.modelValue,
(modelValue) => {
editorService.set('root', modelValue || null);
editorService.set('root', modelValue || null, { historySource: 'initial' });
},
{
immediate: true,

View File

@ -168,7 +168,7 @@ onBeforeUnmount(() => {
const saveCode = (value: string) => {
try {
const parseDSL = getEditorConfig('parseDSL');
editorService.set('root', parseDSL(value));
editorService.set('root', parseDSL(value), { historySource: 'root-code' });
} catch (e: any) {
console.error(e);
}

View File

@ -11,7 +11,7 @@
v-for="group in groups"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
:expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
:goto-enabled="config.gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', bucketId, index)"
@ -36,10 +36,10 @@
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { computed } from 'vue';
import type { BaseStepValue } from '@editor/type';
import type { BaseStepValue, HistoryBucketConfig } from '@editor/type';
import type { HistoryBucketConfig, HistoryBucketGroup, HistoryRowGroup } from './composables';
import { toRowGroup } from './composables';
import type { HistoryBucketGroup, HistoryRowGroup } from './composables';
import { isHistoryGroupExpanded, toRowGroup } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
@ -57,7 +57,7 @@ const props = defineProps<{
bucketId: string | number;
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
groups: HistoryBucketGroup<T>[];
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
/** 共享的折叠状态表key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
}>();

View File

@ -27,10 +27,10 @@
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { TMagicScrollbar } from '@tmagic/design';
import type { BaseStepValue } from '@editor/type';
import type { BaseStepValue, HistoryBucketConfig } from '@editor/type';
import Bucket from './Bucket.vue';
import type { HistoryBucketConfig, HistoryBucketGroup } from './composables';
import type { HistoryBucketGroup } from './composables';
defineOptions({
name: 'MEditorHistoryListBucketTab',

View File

@ -115,7 +115,7 @@ const props = withDefaults(
* 来源 / 时间等头部信息以及子步列表原先散落的十余个扁平 props 收敛于此单一对象
*/
group: HistoryRowGroup;
/** 当前组是否处于展开状态。仅在合并组(子步数 > 1时生效控制子步列表是否渲染。 */
/** 当前组是否处于展开状态(合并组默认展开)。仅在合并组(子步数 > 1时生效控制子步列表是否渲染。 */
expanded: boolean;
/**
* 是否支持跳转到该记录(goto)默认 true

View File

@ -23,6 +23,7 @@
<PageTab
:list="pageGroupsDisplay"
:expanded="expanded"
:marker="pageMarker"
@toggle="toggleGroup"
@goto="onPageGoto"
@goto-initial="onPageGotoInitial"
@ -132,10 +133,15 @@ import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import type { CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, HistoryListExtraTab } from '@editor/type';
import type {
CodeBlockStepValue,
DataSourceStepValue,
DiffDialogPayload,
HistoryBucketConfig,
HistoryListExtraTab,
} from '@editor/type';
import BucketTab from './BucketTab.vue';
import type { HistoryBucketConfig } from './composables';
import {
describeCodeBlockGroup,
describeCodeBlockStep,
@ -206,6 +212,12 @@ const {
codeBlockGroupsByTarget,
} = useHistoryList();
/**
* 当前活动页的加载/初始标记记录设置 root 时生成透传给 PageTab 的底部初始行展示
* 基于 historyService reactive state 派生活动页切换或标记写入后自动刷新
*/
const pageMarker = computed(() => historyService.getPageMarker());
/** 数据源 step 仅 update前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);

View File

@ -2,11 +2,12 @@
<li
class="m-editor-history-list-item m-editor-history-list-initial"
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
:title="rowTitle"
>
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0未修改的初始状态">#0</span>
<span class="m-editor-history-list-item-op op-initial">初始</span>
<span class="m-editor-history-list-item-desc">未修改的初始状态</span>
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span v-if="time" class="m-editor-history-list-item-time" :title="timeTitle">{{ time }}</span>
<span
v-if="gotoEnabled && !isCurrent"
class="m-editor-history-list-item-goto"
@ -22,9 +23,17 @@
* 初始状态记录行渲染于历史列表底部作为整个栈的"零点"
* - 点击该行会把对应栈撤销到 cursor === 0即没有任何已应用步骤等同于回到所有修改之前
* - 当对应栈本身已处于 cursor === 0 isCurrent=true用户已在初始状态点击不再触发动作
* - 当上层传入 `marker`设置 root 时为该页生成的未修改的初始状态标记
* 用标记的文案与时间渲染本行标记不进入撤销/重做栈仅作为该页基线展示
*
* 该行不是真实 step仅作为 UI 入口上层负责把"点击"翻译为 `service.goto*(0)`
*/
import { computed } from 'vue';
import type { StepValue } from '@editor/type';
import { formatHistoryFullTime, formatHistoryTime } from './composables';
defineOptions({
name: 'MEditorHistoryListInitialRow',
});
@ -34,12 +43,23 @@ const props = withDefaults(
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
isCurrent: boolean;
gotoEnabled?: boolean;
/** 该页面的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue提供时用其文案与时间展示。 */
marker?: StepValue;
}>(),
{
gotoEnabled: true,
marker: undefined,
},
);
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
const rowTitle = computed(() => {
const base = props.marker?.historyDescription || '未修改的初始状态';
return props.isCurrent ? `当前已回到${base}` : `点击回到${base}`;
});
const emit = defineEmits<{
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
(_e: 'goto-initial'): void;

View File

@ -1,7 +1,7 @@
<template>
<div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div>
<div v-if="!list.length && !marker" class="m-editor-history-list-empty">暂无操作记录</div>
<template v-else>
<div class="m-editor-history-list-toolbar">
<div v-if="list.length" class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" title="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
</div>
<TMagicScrollbar max-height="360px">
@ -10,7 +10,7 @@
v-for="group in list"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
:expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@ -19,8 +19,9 @@
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
作为"未修改"零点当所有 group 都未 applied 时它即为当前位置
设置 root 时生成的未修改的初始状态标记marker会作为该行的文案与时间来源
-->
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
<InitialRow :is-current="isInitial" :marker="marker" @goto-initial="$emit('goto-initial')" />
</ul>
</TMagicScrollbar>
</template>
@ -31,10 +32,16 @@ import { computed } from 'vue';
import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup, StepValue } from '@editor/type';
import type { HistoryRowDescriptor, PageHistoryGroup, StepValue } from '@editor/type';
import type { HistoryRowDescriptor, HistoryRowGroup } from './composables';
import { describePageGroup, describePageStep, isPageStepRevertable, toRowGroup } from './composables';
import type { HistoryRowGroup } from './composables';
import {
describePageGroup,
describePageStep,
isHistoryGroupExpanded,
isPageStepRevertable,
toRowGroup,
} from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
@ -46,11 +53,16 @@ const props = defineProps<{
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
list: PageHistoryGroup[];
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* 共享的折叠状态表key -> 是否展开缺省或 true 为展开false 为收起由顶层 panel 统一维护
* tab 使用 `pg-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新新增 / 撤销重做导致列表顺序变化已展开的分组状态仍能正确保持
*/
expanded: Record<string, boolean>;
/**
* 当前活动页的加载/初始基线记录设置 root 时生成的 `opType: 'initial'` StepValue
* 提供时即使没有任何操作记录也会展示底部初始行并用其文案 / 时间渲染
*/
marker?: StepValue;
}>();
defineEmits<{
@ -95,8 +107,8 @@ const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, ro
/**
* 是否处于"初始状态"即对应页面历史栈 cursor===0
* list 中所有 group applied 都为 false 时即为该状态
* 没有任何 group 的情况由外层"暂无操作记录"分支兜底本计算可以不考虑
* list 中所有 group applied 都为 false 时即为该状态空列表 `every` 返回 true
* 即仅有 marker无任何操作记录时也视为处于初始状态
*/
const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied));
const isInitial = computed(() => props.list.every((g) => !g.applied));
</script>

View File

@ -11,6 +11,7 @@ import type {
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
HistoryRowDescriptor,
PageHistoryGroup,
StepValue,
} from '@editor/type';
@ -30,37 +31,6 @@ export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
* + / /
* describe* / isStep* props
*/
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
/** 组级描述文案生成器,接收一个 group返回展示文本。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本合并组展开后的子步列表用。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords。不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
}
/**
* bucket / /
* Bucket / BucketTab title / prefix / describe* / isStep* / showInitial / gotoEnabled
* prop
*/
export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
/** bucket 头部标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
showInitial?: boolean;
/** 是否支持「跳转到该记录」(goto),默认 true。 */
gotoEnabled?: boolean;
}
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step。 */
export interface HistoryRowStep {
/** 该子步在所属栈中的稳定索引。 */
@ -110,6 +80,9 @@ export interface HistoryRowGroup {
subSteps: HistoryRowStep[];
}
/** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
/**
*
* - / /
@ -124,10 +97,11 @@ export const useHistoryList = () => {
/**
* key `pg-${ index}` / `ds-${id}-${ index}` / `cb-${id}-${ index}`
* index key
* `false`
*/
const expanded = reactive<Record<string, boolean>>({});
const toggleGroup = (key: string) => {
expanded[key] = !expanded[key];
expanded[key] = expanded[key] === false;
};
const pageGroups = computed(() => historyService.getPageHistoryGroups());
@ -211,6 +185,7 @@ const HISTORY_SOURCE_LABELS: Record<string, string> = {
'component-panel': '组件面板',
props: '配置面板',
code: '源码',
'root-code': 'DSL源码',
'stage-contextmenu': '画布菜单',
'tree-contextmenu': '树菜单',
toolbar: '工具栏',
@ -218,6 +193,8 @@ const HISTORY_SOURCE_LABELS: Record<string, string> = {
rollback: '回滚',
api: '接口',
ai: 'AI',
initial: '初始值',
sync: '同步',
unknown: '未知',
};

View File

@ -17,7 +17,7 @@
*/
import { reactive, toRaw } from 'vue';
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
@ -60,6 +60,7 @@ import {
classifyDragSources,
collectRelatedNodes,
COPY_STORAGE_KEY,
describeStepForRevert,
editorNodeMergeCustomizer,
fixNodePosition,
getInitPositionStyle,
@ -76,46 +77,6 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
/**
* step
* UI `describePageStep` service layouts/
*/
const describeStepForRevert = (step: StepValue): string => {
const items = step.diff ?? [];
// 在可读名后拼接组件 id便于在历史面板中精确定位被回滚的组件id 缺失时退化为仅展示名称。
const withId = (node: MNode | undefined, label: string): string => {
const id = node?.id;
if (id === undefined || id === null || `${id}` === '') return label;
return label ? `${label}id: ${id}` : `id: ${id}`;
};
switch (step.opType) {
case 'add': {
const count = items.length;
const node = items[0]?.newSchema;
const label = node?.name || node?.type || '';
return `撤回新增 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'remove': {
const count = items.length;
const node = items[0]?.oldSchema;
const label = node?.name || node?.type || '';
return `还原已删除的 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'update':
default: {
if (items.length === 1) {
const { newSchema, oldSchema, changeRecords } = items[0];
const node = newSchema || oldSchema;
const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || '';
const target = withId(node, label);
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
}
return `还原 ${items.length} 个节点的修改`;
}
}
};
/**
* update {@link StepDiffItem} {@link StepValue.diff} 使
* `changeRecords` form propPath/value / propPath
@ -166,8 +127,13 @@ class Editor extends BaseService {
*
* @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
* @param value MNode
* @param options.historySource root name === 'root'
*/
public set<K extends StoreStateKey, T extends StoreState[K]>(name: K, value: T) {
public set<K extends StoreStateKey, T extends StoreState[K]>(
name: K,
value: T,
options: { historySource?: HistoryOpSource } = {},
) {
const preValue = this.state[name];
this.state[name] = value;
@ -186,6 +152,25 @@ class Editor extends BaseService {
this.state.pageLength = getPageList(app).length || 0;
this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
this.state.stageLoading = this.state.pageLength !== 0;
if (preValue && !isEmpty(preValue)) {
// 编辑期间再次整体设置 root源码保存 / 外部重设 DSL / root 节点更新):与上一次 root
// 做页面级 diff按 update / add / remove 入栈,作为正常历史记录体现整体替换。
this.pushRootDiffHistory(preValue as MApp, app, options.historySource);
} else {
// 首次设置 root仅当该页面 / 页面片尚无基线标记时,才写入「未修改的初始状态」基线。
// 配合「先恢复历史再 set root」若基线已随历史恢复建立恢复后已有基线则此处不再
// 重复创建set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
app.items?.forEach((pageNode) => {
if (pageNode?.id !== undefined && !historyService.getPageMarker(pageNode.id)) {
historyService.setPageMarker(pageNode.id, {
name: pageNode.name,
source: options.historySource,
});
}
});
}
} else {
this.state.pageLength = 0;
this.state.pageFragmentLength = 0;
@ -695,7 +680,7 @@ class Editor extends BaseService {
public async doUpdate(
config: MNode,
{ changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
{ changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -715,7 +700,7 @@ class Editor extends BaseService {
if (!newConfig.type) throw new Error('配置缺少type值');
if (newConfig.type === NodeType.ROOT) {
this.set('root', newConfig as MApp);
this.set('root', newConfig as MApp, { historySource });
return {
oldNode: node,
newNode: newConfig,
@ -796,7 +781,7 @@ class Editor extends BaseService {
const updateData = await Promise.all(
nodes.map((node, index) => {
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
return this.doUpdate(node, { changeRecords: recordsForNode });
return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
}),
);
@ -1374,6 +1359,8 @@ class Editor extends BaseService {
if (!entry?.applied) return null;
const { step } = entry;
// 初始基线index 0 的 initial step是栈底线不可回滚。
if (step.opType === 'initial') return null;
const root = this.get('root');
if (!root) return null;
@ -1573,6 +1560,86 @@ class Editor extends BaseService {
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
}
/**
* root root /
* - `update` changeRecords
* - root `add`
* - root `remove`
*
* `source`
* DSL
*/
private pushRootDiffHistory(preRoot: MApp, nextRoot: MApp, source?: HistoryOpSource): void {
const prevPages = preRoot?.items || [];
const nextPages = nextRoot?.items || [];
const prevMap = new Map(prevPages.map((p) => [`${p.id}`, p]));
const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
nextPages.forEach((nextPage) => {
const prevPage = prevMap.get(`${nextPage.id}`);
if (!prevPage) {
this.pushPageDiffStep(
'add',
nextPage,
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
source,
);
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
this.pushPageDiffStep(
'update',
nextPage,
{ oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
source,
);
}
});
prevPages.forEach((prevPage) => {
if (!nextMap.has(`${prevPage.id}`)) {
this.pushPageDiffStep(
'remove',
prevPage,
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
source,
);
}
});
}
/**
* set root / modifiedNodeIds
*
* set root **** set root {@link StepValue.rootStep} `source`
* **** / DSL root
* initial 线 rootStep
*/
private pushPageDiffStep(
opType: HistoryOpType,
page: MPage | MPageFragment,
diffItem: StepDiffItem<MNode>,
source?: HistoryOpSource,
): void {
const step: StepValue = {
uuid: guid(),
data: { name: page.name || '', id: page.id },
opType,
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
diff: [diffItem],
rootStep: true,
};
if (source) step.source = source;
const top = historyService.getCurrentPageStep(page.id);
if (top?.rootStep && top.source === source) {
historyService.replaceCurrentPageStep(step, page.id);
} else {
historyService.push(step, page.id);
}
}
private pushOpHistory(
opType: HistoryOpType,
{
@ -1621,6 +1688,8 @@ class Editor extends BaseService {
* @param reverse true = false =
*/
private async applyHistoryOp(step: StepValue, reverse: boolean) {
// 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
if (step.opType === 'initial') return;
const root = this.get('root');
const stage = this.get('stage');
if (!root) return;

View File

@ -17,7 +17,6 @@
*/
import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
@ -25,13 +24,11 @@ import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
HistoryPersistOptions,
HistoryState,
PageHistoryGroup,
@ -40,6 +37,16 @@ import type {
StepValue,
} from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import {
createStackStep,
deserializeStacks,
getOrCreateStack,
markStackSaved,
mergePageSteps,
mergeStackSteps,
serializeStacks,
undoFloor,
} from '@editor/utils/history';
import { idbGet, idbSet } from '@editor/utils/indexed-db';
import { UndoRedo } from '@editor/utils/undo-redo';
@ -53,199 +60,6 @@ const DEFAULT_KEY: IDBValidKey = 'default';
const PERSIST_VERSION = 1;
class History extends BaseService {
/**
* id / group
* - "新增/删除" update
* - 'update' steps
*
* `kind` `kind`
*/
private static mergeStackSteps<S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
kind: K,
id: Id,
list: S[],
cursor: number,
): {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
}[] {
type Group = {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
};
const groups: Group[] = [];
let current: Group | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const { opType } = step;
const applied = index < cursor;
const isCurrent = index === currentIndex;
if (opType === 'update' && current?.opType === 'update') {
current.steps.push({ step, index, applied, isCurrent });
current.applied = applied;
if (isCurrent) current.isCurrent = true;
} else {
current = {
kind,
id,
opType,
steps: [{ step, index, applied, isCurrent }],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
}
/**
* old/new null opType push
*/
private static detectOpType(oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' {
if (oldVal === null && newVal !== null) return 'add';
if (oldVal !== null && newVal === null) return 'remove';
return 'update';
}
/**
* group
* - 'update' targetId targetId update group
* - 'add' / 'remove'
* - 'update'
*/
private static mergePageSteps(pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] {
const groups: PageHistoryGroup[] = [];
let current: PageHistoryGroup | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const applied = index < cursor;
const isCurrent = index === currentIndex;
const targetId = History.detectPageTargetId(step);
const targetName = History.detectPageTargetName(step);
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
// 仅"单节点 update"参与合并其它情形add/remove/多节点 update始终独立成组。
const mergeable = step.opType === 'update' && targetId !== undefined;
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
current.steps.push(entry);
current.applied = applied;
if (isCurrent) current.isCurrent = true;
// 保持目标名为最近一次的(节点重命名时也能反映)
if (targetName) current.targetName = targetName;
} else {
current = {
kind: 'page',
pageId,
opType: step.opType,
targetId: mergeable ? targetId : undefined,
targetName,
steps: [entry],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
}
/**
* StepValue "目标节点 id"
* - update updatedItems id
* - update / add / remove undefined
*/
private static detectPageTargetId(step: StepValue): Id | undefined {
if (step.opType !== 'update') return undefined;
const items = step.diff;
if (items?.length !== 1) return undefined;
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
}
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
private static detectPageTargetName(step: StepValue): string | undefined {
const items = step.diff;
if (step.opType === 'update') {
if (items?.length === 1) {
const node = items[0].newSchema || items[0].oldSchema;
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'add') {
if (items?.length === 1) {
const n = items[0].newSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'remove') {
if (items?.length === 1) {
const n = items[0].oldSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
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;
}
/**
* id id / UndoRedo
*/
private static getOrCreateStack<T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> {
if (!stacks[id]) {
stacks[id] = new UndoRedo<T>();
}
return stacks[id];
}
public state = reactive<HistoryState>({
pageSteps: {},
pageId: undefined,
@ -297,6 +111,59 @@ class History extends BaseService {
this.state.dataSourceState = {};
}
/**
* / 线 DSL / 线
*
* `opType: 'initial'` {@link StepValue} **index 0 线**
* - step/cursor
* undo / goto / revert {@link undo} / {@link setCanUndoRedo}
* - {@link getPageHistoryGroups}
*
* initial index 0 initial 线
* `force=true` 线
*/
public setPageMarker(
pageId: Id,
options: { name?: string; description?: string; source?: HistoryOpSource } = {},
): StepValue | null {
if (pageId === undefined || pageId === null || `${pageId}` === '') return null;
const existing = this.getPageMarker(pageId);
if (existing) return existing;
const stack = getOrCreateStack(this.state.pageSteps, pageId);
// initial 必须是 index 0栈非空已有真实记录、却无 initial如旧数据时不强行前插优雅降级为无基线。
if (stack.getLength() > 0) return null;
const marker: StepValue = {
uuid: guid(),
opType: 'initial',
diff: [],
data: { name: options.name || '', id: pageId },
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
historyDescription: options.description || '未修改的初始状态',
timestamp: Date.now(),
...(options.source ? { source: options.source } : {}),
};
stack.pushElement(marker);
if (`${pageId}` === `${this.state.pageId}`) this.setCanUndoRedo();
this.emit('page-marker-change', marker);
return marker;
}
/**
* 线 step index 0 `opType: 'initial'`
* undefined
*/
public getPageMarker(pageId?: Id): StepValue | undefined {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return undefined;
const first = this.state.pageSteps[targetPageId]?.getElementList()[0];
return first?.opType === 'initial' ? first : undefined;
}
/**
* pageId
*
@ -316,6 +183,27 @@ class History extends BaseService {
return state;
}
/** 读取指定页面(缺省当前活动页)历史栈当前游标所在的 stepcursor - 1无则返回 null。 */
public getCurrentPageStep(pageId?: Id): StepValue | null {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return null;
return this.state.pageSteps[targetPageId]?.getCurrentElement() ?? null;
}
/**
* `state` step
* set root /
*/
public replaceCurrentPageStep(state: StepValue, pageId?: Id): StepValue | null {
const undoRedo = this.getUndoRedo(pageId);
if (!undoRedo) return null;
if (state.uuid === undefined) state.uuid = guid();
if (state.timestamp === undefined) state.timestamp = Date.now();
if (!undoRedo.replaceCurrentElement(state)) return null;
this.emit('change', state);
return state;
}
/**
* / `codeBlockId` UndoRedo
*
@ -337,7 +225,7 @@ class History extends BaseService {
source?: HistoryOpSource;
},
): CodeBlockStepValue | null {
const step = this.createStackStep<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
@ -345,7 +233,7 @@ class History extends BaseService {
source: payload.source,
});
if (!step) return null;
History.getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step);
getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step);
this.emit('code-block-history-change', codeBlockId, step);
return step;
}
@ -366,7 +254,7 @@ class History extends BaseService {
source?: HistoryOpSource;
},
): DataSourceStepValue | null {
const step = this.createStackStep<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
@ -374,7 +262,7 @@ class History extends BaseService {
source: payload.source,
});
if (!step) return null;
History.getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step);
getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step);
this.emit('data-source-history-change', dataSourceId, step);
return step;
}
@ -438,6 +326,8 @@ class History extends BaseService {
public undo(): StepValue | null {
const undoRedo = this.getUndoRedo();
if (!undoRedo) return null;
// 不允许撤销越过初始基线index 0 的 initial step
if (undoRedo.getCursor() <= undoFloor(undoRedo)) return null;
const state = undoRedo.undo();
this.emit('change', state);
return state;
@ -464,7 +354,16 @@ class History extends BaseService {
public clearPage(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
// 保留该页原 initial 基线的文案 / 来源(仅清空其后的真实操作记录),无基线时清空成空栈。
const marker = this.getPageMarker(targetPageId);
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
if (marker) {
this.setPageMarker(targetPageId, {
name: marker.data?.name,
description: marker.historyDescription,
source: marker.source,
});
}
if (`${targetPageId}` === `${this.state.pageId}`) {
this.setCanUndoRedo();
this.emit('change', null);
@ -501,9 +400,9 @@ class History extends BaseService {
* {@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);
Object.values(this.state.pageSteps).forEach(markStackSaved);
Object.values(this.state.codeBlockState).forEach(markStackSaved);
Object.values(this.state.dataSourceState).forEach(markStackSaved);
this.emit('mark-saved', { kind: 'all' });
}
@ -514,21 +413,21 @@ class History extends BaseService {
public markPageSaved(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
History.markStackSaved(this.state.pageSteps[targetPageId]);
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]);
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]);
markStackSaved(this.state.dataSourceState[dataSourceId]);
this.emit('mark-saved', { kind: 'data-source', id: dataSourceId });
}
@ -541,20 +440,20 @@ class History extends BaseService {
* - IndexedDB SSR reject
*/
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = 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),
pageSteps: serializeStacks(this.state.pageSteps),
codeBlockState: serializeStacks(this.state.codeBlockState),
dataSourceState: serializeStacks(this.state.dataSourceState),
savedAt: Date.now(),
};
// 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法IndexedDB 的结构化克隆无法写入函数,
// 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
await idbSet(this.resolveDbName(dbName), storeName, key, serialize(snapshot));
await idbSet(this.resolveDbName(dbName, appId), storeName, key, serialize(snapshot));
this.emit('save-to-indexed-db', snapshot);
return snapshot;
}
@ -568,18 +467,19 @@ class History extends BaseService {
* - 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 { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName), storeName, key);
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName, appId), 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.pageSteps = deserializeStacks(snapshot.pageSteps);
this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState);
this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState);
// initial 基线作为页面栈 index 0 的 step 随 pageSteps 一并还原,无需单独恢复。
this.state.pageId = snapshot.pageId;
this.setCanUndoRedo();
@ -621,7 +521,9 @@ class History extends BaseService {
const list = undoRedo.getElementList();
if (!list.length) return [];
const cursor = undoRedo.getCursor();
return History.mergePageSteps(targetPageId, list, cursor);
// initial 基线index 0不作为普通操作组展示过滤掉其余真实 step 的 index 保持不变,
// 以便面板 goto(index+1) / revert(index) 仍直接对应栈内位置。底部「初始」行由 getPageMarker 驱动。
return mergePageSteps(targetPageId, list, cursor).filter((group) => group.opType !== 'initial');
}
/**
@ -638,7 +540,7 @@ class History extends BaseService {
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeStackSteps('code-block', id, list, cursor));
groups.push(...mergeStackSteps('code-block', id, list, cursor));
});
return groups;
}
@ -730,7 +632,7 @@ class History extends BaseService {
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeStackSteps('data-source', id, list, cursor));
groups.push(...mergeStackSteps('data-source', id, list, cursor));
});
return groups;
}
@ -754,59 +656,17 @@ class History extends BaseService {
* 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 resolveDbName(dbName: string, appId?: Id): string {
// 优先用显式传入的 appId「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
const resolvedAppId = appId ?? editorService.get('root')?.id;
return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
}
private setCanUndoRedo(): void {
const undoRedo = this.getUndoRedo();
this.state.canRedo = undoRedo?.canRedo() || false;
this.state.canUndo = undoRedo?.canUndo() || false;
}
/**
* / id payload
*
* - `add`oldValue = null`remove`newValue = null`update` changeRecords
* - cloneDeep opType old/new null
* - step emit pushCodeBlock / pushDataSource
* - service
*/
private createStackStep<T, S extends BaseStepValue<T> & { id: Id }>(
id: Id,
payload: {
oldValue: T | null;
newValue: T | null;
changeRecords?: ChangeRecord[];
historyDescription?: string;
source?: HistoryOpSource;
},
): S | null {
if (!id) return null;
const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null;
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
const opType = History.detectOpType(payload.oldValue, payload.newValue);
const step: BaseStepValue<T> & { id: Id } = {
uuid: guid(),
id,
opType,
diff: [
{
...(newSchema !== null ? { newSchema } : {}),
...(oldSchema !== null ? { oldSchema } : {}),
...(opType === 'update' && changeRecords ? { changeRecords } : {}),
},
],
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
return step as S;
// 初始基线之上才可撤销cursor 必须高于底线(有 initial 时为 1
this.state.canUndo = undoRedo ? undoRedo.getCursor() > undoFloor(undoRedo) : false;
}
}

View File

@ -680,7 +680,13 @@ export interface CodeParamStatement {
}
// #region HistoryOpType
export type HistoryOpType = 'add' | 'remove' | 'update';
/**
*
* - `add` / `remove` / `update`/
* - `initial`线 root index 0 线 step
* step /cursor
*/
export type HistoryOpType = 'add' | 'remove' | 'update' | 'initial';
// #endregion HistoryOpType
// #region HistoryOpSource
@ -705,11 +711,13 @@ export type HistoryOpType = 'add' | 'remove' | 'update';
* `(string & {})`
*/
export type HistoryOpSource =
| 'initial'
| 'stage'
| 'tree'
| 'component-panel'
| 'props'
| 'code'
| 'root-code'
| 'stage-contextmenu'
| 'tree-contextmenu'
| 'toolbar'
@ -717,6 +725,8 @@ export type HistoryOpSource =
| 'rollback'
| 'api'
| 'ai'
// 同步
| 'sync'
| 'unknown'
| (string & {});
// #endregion HistoryOpSource
@ -790,6 +800,12 @@ export interface BaseStepValue<T = unknown> {
* true IndexedDB
*/
saved?: boolean;
/**
* rootset root {@link Editor.pushRootDiffHistory}
* set root root set root
* / DSL root
*/
rootStep?: boolean;
}
// #endregion BaseStepValue
@ -880,6 +896,12 @@ export interface HistoryPersistOptions {
storeName?: string;
/** 记录 key用于区分不同活动页 / 项目,默认 `default`。 */
key?: IDBValidKey;
/**
* DSL app id
* 退 editorService `root.id` set root root
* DSL id / app
*/
appId?: Id;
}
// #endregion HistoryPersistOptions
@ -1248,3 +1270,34 @@ export interface DiffDialogPayload {
/** 用于标题展示的目标 id */
id?: string | number;
}
/**
* + / /
* describe* / isStep* props
*/
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
/** 组级描述文案生成器,接收一个 group返回展示文本。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本合并组展开后的子步列表用。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords。不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
}
/**
* bucket / /
* Bucket / BucketTab title / prefix / describe* / isStep* / showInitial / gotoEnabled
* prop
*/
export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
/** bucket 头部标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
showInitial?: boolean;
/** 是否支持「跳转到该记录」(goto),默认 true。 */
gotoEnabled?: boolean;
}

View File

@ -35,7 +35,7 @@ import {
isValueIncludeDataSource,
} from '@tmagic/utils';
import type { EditorNodeInfo } from '@editor/type';
import type { EditorNodeInfo, StepValue } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
@ -684,3 +684,43 @@ export const classifyDragSources = (
return { sameParentIndices, crossParentConfigs, aborted: false };
};
/**
* step
* UI `describePageStep` service layouts/
*/
export const describeStepForRevert = (step: StepValue): string => {
const items = step.diff ?? [];
// 在可读名后拼接组件 id便于在历史面板中精确定位被回滚的组件id 缺失时退化为仅展示名称。
const withId = (node: MNode | undefined, label: string): string => {
const id = node?.id;
if (id === undefined || id === null || `${id}` === '') return label;
return label ? `${label}id: ${id}` : `id: ${id}`;
};
switch (step.opType) {
case 'add': {
const count = items.length;
const node = items[0]?.newSchema;
const label = node?.name || node?.type || '';
return `撤回新增 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'remove': {
const count = items.length;
const node = items[0]?.oldSchema;
const label = node?.name || node?.type || '';
return `还原已删除的 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'update':
default: {
if (items.length === 1) {
const { newSchema, oldSchema, changeRecords } = items[0];
const node = newSchema || oldSchema;
const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || '';
const target = withId(node, label);
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
}
return `还原 ${items.length} 个节点的修改`;
}
}
};

View File

@ -16,9 +16,23 @@
* limitations under the License.
*/
import type { Id } from '@tmagic/core';
import { cloneDeep } from 'lodash-es';
import type { StepDiffItem } from '@editor/type';
import type { Id } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type {
BaseStepValue,
HistoryOpSource,
HistoryOpType,
PageHistoryGroup,
PageHistoryStepEntry,
StepDiffItem,
StepValue,
} from '@editor/type';
import { UndoRedo } from './undo-redo';
/**
* step /
@ -41,3 +55,244 @@ export const describeRevertStep = <T extends object>(
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${label} · ${propPath}` : `还原 ${label}`;
};
/**
* old/new null opType push
*/
export const detectStackOpType = (oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' => {
if (oldVal === null && newVal !== null) return 'add';
if (oldVal !== null && newVal === null) return 'remove';
return 'update';
};
/**
* / id payload
*
* - `add`oldValue = null`remove`newValue = null`update` changeRecords
* - cloneDeep opType old/new null
* - step emit pushCodeBlock / pushDataSource
* - service
*/
export const createStackStep = <T, S extends BaseStepValue<T> & { id: Id }>(
id: Id,
payload: {
oldValue: T | null;
newValue: T | null;
changeRecords?: ChangeRecord[];
historyDescription?: string;
source?: HistoryOpSource;
},
): S | null => {
if (!id) return null;
const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null;
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
const opType = detectStackOpType(payload.oldValue, payload.newValue);
const step: BaseStepValue<T> & { id: Id } = {
uuid: guid(),
id,
opType,
diff: [
{
...(newSchema !== null ? { newSchema } : {}),
...(oldSchema !== null ? { oldSchema } : {}),
...(opType === 'update' && changeRecords ? { changeRecords } : {}),
},
],
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
return step as S;
};
export const markStackSaved = <S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void => {
if (!undoRedo) return;
undoRedo.updateElements((element) => {
element.saved = false;
});
undoRedo.updateCurrentElement((element) => {
element.saved = true;
});
};
/**
* id / group
* - "新增/删除" update
* - 'update' steps
*
* `kind` `kind`
*/
export const mergeStackSteps = <S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
kind: K,
id: Id,
list: S[],
cursor: number,
): {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
}[] => {
type Group = {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
};
const groups: Group[] = [];
let current: Group | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const { opType } = step;
const applied = index < cursor;
const isCurrent = index === currentIndex;
if (opType === 'update' && current?.opType === 'update') {
current.steps.push({ step, index, applied, isCurrent });
current.applied = applied;
if (isCurrent) current.isCurrent = true;
} else {
current = {
kind,
id,
opType,
steps: [{ step, index, applied, isCurrent }],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
};
/**
* group
* - 'update' targetId targetId update group
* - 'add' / 'remove'
* - 'update'
*/
export const mergePageSteps = (pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] => {
const groups: PageHistoryGroup[] = [];
let current: PageHistoryGroup | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const applied = index < cursor;
const isCurrent = index === currentIndex;
const targetId = detectPageTargetId(step);
const targetName = detectPageTargetName(step);
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
// 仅"单节点 update"参与合并其它情形add/remove/多节点 update始终独立成组。
const mergeable = step.opType === 'update' && targetId !== undefined;
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
current.steps.push(entry);
current.applied = applied;
if (isCurrent) current.isCurrent = true;
// 保持目标名为最近一次的(节点重命名时也能反映)
if (targetName) current.targetName = targetName;
} else {
current = {
kind: 'page',
pageId,
opType: step.opType,
targetId: mergeable ? targetId : undefined,
targetName,
steps: [entry],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
};
/**
* StepValue "目标节点 id"
* - update updatedItems id
* - update / add / remove undefined
*/
export const detectPageTargetId = (step: StepValue): Id | undefined => {
if (step.opType !== 'update') return undefined;
const items = step.diff;
if (items?.length !== 1) return undefined;
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
};
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
export const detectPageTargetName = (step: StepValue): string | undefined => {
const items = step.diff;
if (step.opType === 'update') {
if (items?.length === 1) {
const node = items[0].newSchema || items[0].oldSchema;
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'add') {
if (items?.length === 1) {
const n = items[0].newSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'remove') {
if (items?.length === 1) {
const n = items[0].oldSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
return undefined;
};
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
export const 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`
*/
export const 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;
};
/**
* id id / UndoRedo
*/
export const getOrCreateStack = <T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> => {
if (!stacks[id]) {
stacks[id] = new UndoRedo<T>();
}
return stacks[id];
};
/**
* index 0 `opType: 'initial'` 线 step 1线 0
* cursor 线 undo / canUndo / goto 线
*/
export const undoFloor = (undoRedo: UndoRedo<StepValue>): number => {
return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
};

View File

@ -139,6 +139,17 @@ export class UndoRedo<T = any> {
return cloneDeep(this.elementList[this.listCursor - 1]);
}
/**
* `element` cursor - 1 {@link pushElement}
* cursor 0 false
*
*/
public replaceCurrentElement(element: T): boolean {
if (this.listCursor < 1) return false;
this.elementList.splice(this.listCursor - 1, this.elementList.length - (this.listCursor - 1), cloneDeep(element));
return true;
}
/**
* cursor - 1cursor 0
*

View File

@ -179,7 +179,7 @@ describe('initServiceState', () => {
test('modelValue 变化设置 editor root', () => {
const props = { modelValue: { id: 'a' } } as any;
mount(Wrap(props, services));
expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' });
expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' }, { historySource: 'initial' });
});
test('disabledMultiSelect/alwaysMultiSelect 设置', () => {

View File

@ -145,7 +145,7 @@ describe('Framework', () => {
});
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
await wrapper.find('.fake-code-editor').trigger('click');
expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' });
expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' }, { historySource: 'root-code' });
});
test('SplitView change 写入 uiService 和 storage', async () => {

View File

@ -7,7 +7,7 @@ import { describe, expect, test } from 'vitest';
import { mount } from '@vue/test-utils';
import Bucket from '@editor/layouts/history-list/Bucket.vue';
import type { HistoryBucketConfig } from '@editor/layouts/history-list/composables';
import type { HistoryBucketConfig } from '@editor/type';
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true): any => ({
applied,
@ -137,8 +137,8 @@ describe('Bucket.vue', () => {
// 第二组只有 1 步,不应渲染 substeps即使 expanded 为 true
const rows = wrapper.findAll('.m-editor-history-list-group');
expect(rows[1].find('.m-editor-history-list-substeps').exists()).toBe(false);
// 第一组未展开,也不应有 substeps
expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(false);
// 第一组为合并组,默认展开
expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(true);
});
test('groups 非空时底部追加初始项;点击透传 goto-initial 携带 bucketId', async () => {

View File

@ -152,17 +152,17 @@ describe('HistoryListPanel.vue', () => {
const head = wrapper.find('.m-editor-history-list-group-head');
expect(head.exists()).toBe(true);
// 默认未展开
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
// 点击展开
await head.trigger('click');
// 默认展开
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
// 合并组头部点击不应触发 goto
expect(editorService.gotoPageStep).not.toHaveBeenCalled();
// 再点击折叠
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
// 点击收起
await head.trigger('click');
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
// 再点击展开
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
});
test('点击页面 group 头部调用 editorService.gotoPageStep', async () => {

View File

@ -124,11 +124,11 @@ describe('PageTab.vue', () => {
'btn',
);
const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: { 'pg-0': true } } });
const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: {} } });
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
await wrapper.setProps({ list: [mergedGroup], expanded: {} });
await wrapper.setProps({ list: [mergedGroup], expanded: { 'pg-0': false } });
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
});

View File

@ -565,9 +565,11 @@ describe('useHistoryList', () => {
return { api, wrapper };
};
test('toggleGroup 切换 expanded[key]', () => {
test('toggleGroup 切换 expanded[key](默认展开)', () => {
const { api } = mountWithHost();
expect(api.expanded.foo).toBeFalsy();
expect(api.expanded.foo).toBeUndefined();
api.toggleGroup('foo');
expect(api.expanded.foo).toBe(false);
api.toggleGroup('foo');
expect(api.expanded.foo).toBe(true);
api.toggleGroup('foo');

View File

@ -56,19 +56,6 @@ afterEach(() => {
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);
@ -77,29 +64,23 @@ describe('history service - markSaved', () => {
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 仅影响对应栈', () => {
test('markPageSaved / markCodeBlockSaved / markDataSourceSaved 派发对应 kind 事件', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const pageFn = vi.fn();
const codeFn = vi.fn();
history.on('mark-saved', (payload) => {
if (payload.kind === 'page') pageFn(payload);
if (payload.kind === 'code-block') codeFn(payload);
});
history.markPageSaved();
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();
expect(pageFn).toHaveBeenCalledWith({ kind: 'page', id: 'p1' });
expect(codeFn).toHaveBeenCalledWith({ kind: 'code-block', id: 'code_1' });
});
});

View File

@ -129,6 +129,40 @@ describe('history service', () => {
expect(s2?.uuid).toBeTruthy();
expect(s1?.uuid).not.toBe(s2?.uuid);
});
test('setPageMarker 在空栈时种入 initial 基线', () => {
history.changePage({ id: 'p1' } as any);
const marker = history.setPageMarker('p1', { name: '首页', description: '初始' });
expect(marker?.opType).toBe('initial');
expect(history.getPageMarker('p1')?.uuid).toBe(marker?.uuid);
expect((history.state.pageSteps as any).p1.getLength()).toBe(1);
});
test('有 initial 基线时不可撤销越过基线', () => {
history.changePage({ id: 'p1' } as any);
history.setPageMarker('p1');
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect(history.state.canUndo).toBe(true);
history.undo();
expect(history.state.canUndo).toBe(false);
expect(history.undo()).toBeNull();
expect(history.getPageCursor('p1')).toBe(1);
});
test('getPageHistoryGroups 过滤 initial 基线', () => {
history.changePage({ id: 'p1' } as any);
history.setPageMarker('p1');
history.push({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
const groups = history.getPageHistoryGroups('p1');
expect(groups).toHaveLength(1);
expect(groups[0].opType).toBe('add');
});
});
describe('history service - codeBlock', () => {

View File

@ -0,0 +1,313 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import type { CodeBlockStepValue, StepValue } from '@editor/type';
import {
createStackStep,
describeRevertStep,
deserializeStacks,
detectPageTargetId,
detectPageTargetName,
detectStackOpType,
getOrCreateStack,
markStackSaved,
mergePageSteps,
mergeStackSteps,
serializeStacks,
undoFloor,
} from '@editor/utils/history';
import { UndoRedo } from '@editor/utils/undo-redo';
describe('detectStackOpType', () => {
test('old=null new=有值 → add', () => {
expect(detectStackOpType(null, {})).toBe('add');
});
test('old=有值 new=null → remove', () => {
expect(detectStackOpType({}, null)).toBe('remove');
});
test('old/new 都有值 → update', () => {
expect(detectStackOpType({}, {})).toBe('update');
});
});
describe('createStackStep', () => {
test('空 id 返回 null', () => {
expect(createStackStep('', { oldValue: null, newValue: { name: 'A' } as any })).toBeNull();
});
test('新增oldValue=null推断 opType=add', () => {
const step = createStackStep('code_1', {
oldValue: null,
newValue: { name: 'A', content: 'x' } as any,
});
expect(step?.opType).toBe('add');
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
});
test('内容 cloneDeep外部修改不影响 step', () => {
const content = { name: 'A' };
const step = createStackStep('code_1', { oldValue: null, newValue: content as any });
content.name = 'B';
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A' });
});
test('update 可携带 changeRecords', () => {
const step = createStackStep('code_1', {
oldValue: { name: 'A' } as any,
newValue: { name: 'B' } as any,
changeRecords: [{ propPath: 'name' }],
});
expect(step?.opType).toBe('update');
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name' }]);
});
});
describe('describeRevertStep', () => {
test('撤回新增', () => {
expect(describeRevertStep('code_1', { newSchema: { name: 'fn' } as any }, (s) => s.name)).toBe('撤回新增 fn');
});
test('还原已删除', () => {
expect(describeRevertStep('code_1', { oldSchema: { name: 'fn' } as any }, (s) => s.name)).toBe('还原已删除的 fn');
});
test('还原修改 + propPath', () => {
expect(
describeRevertStep(
'code_1',
{
oldSchema: { name: 'fn' } as any,
newSchema: { name: 'fn' } as any,
changeRecords: [{ propPath: 'content' }],
},
(s) => s.name,
),
).toBe('还原 fn · content');
});
test('还原修改无 propPath', () => {
expect(
describeRevertStep(
'code_1',
{ oldSchema: { name: 'fn' } as any, newSchema: { name: 'fn' } as any },
(s) => s.name,
),
).toBe('还原 fn');
});
});
describe('markStackSaved', () => {
test('undoRedo 为空时不抛错', () => {
expect(() => markStackSaved(undefined)).not.toThrow();
});
test('标记当前记录为 saved并清除其它记录的 saved', () => {
const undoRedo = new UndoRedo<{ saved?: boolean }>();
undoRedo.pushElement({ saved: false });
undoRedo.pushElement({ saved: false });
markStackSaved(undoRedo);
const list = undoRedo.getElementList();
expect(list.filter((s) => s.saved)).toHaveLength(1);
expect(list[list.length - 1].saved).toBe(true);
expect(list[0].saved).toBeFalsy();
});
});
describe('mergeStackSteps', () => {
test('连续 update 合并为一组', () => {
const list = [
{ opType: 'update', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeStackSteps('code-block', 'code_1', list, 2);
expect(groups).toHaveLength(1);
expect(groups[0].steps).toHaveLength(2);
expect(groups[0].opType).toBe('update');
});
test('add / update 不合并', () => {
const list = [
{ opType: 'add', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeStackSteps('code-block', 'code_1', list, 2);
expect(groups).toHaveLength(2);
});
test('正确标记 applied / isCurrent', () => {
const list = [
{ opType: 'update', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeStackSteps('code-block', 'code_1', list, 1);
expect(groups[0].applied).toBe(false);
expect(groups[0].steps[0].applied).toBe(true);
expect(groups[0].steps[0].isCurrent).toBe(true);
expect(groups[0].steps[1].applied).toBe(false);
});
});
describe('detectPageTargetId / detectPageTargetName', () => {
test('单节点 update 返回 targetId 与名称', () => {
const step = {
opType: 'update',
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
} as unknown as StepValue;
expect(detectPageTargetId(step)).toBe('btn_1');
expect(detectPageTargetName(step)).toBe('按钮');
});
test('多节点 update 不参与合并', () => {
const step = {
opType: 'update',
diff: [
{ newSchema: { id: 'a' }, oldSchema: { id: 'a' } },
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
],
} as unknown as StepValue;
expect(detectPageTargetId(step)).toBeUndefined();
expect(detectPageTargetName(step)).toBe('2 个节点');
});
test('add 单节点返回名称', () => {
const step = {
opType: 'add',
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
} as unknown as StepValue;
expect(detectPageTargetId(step)).toBeUndefined();
expect(detectPageTargetName(step)).toBe('text');
});
});
describe('mergePageSteps', () => {
test('相邻同 targetId 的 update 合并', () => {
const mkUpdate = (path: string) =>
({
opType: 'update',
diff: [
{
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as StepValue;
const list = [mkUpdate('style.color'), mkUpdate('style.fontSize')];
const groups = mergePageSteps('p1', list, 2);
expect(groups).toHaveLength(1);
expect(groups[0].targetId).toBe('btn_1');
expect(groups[0].steps).toHaveLength(2);
});
test('add 始终独立成组', () => {
const list = [
{
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
},
{
opType: 'update',
diff: [{ newSchema: { id: 'n1', name: 'A' }, oldSchema: { id: 'n1', name: 'A' } }],
},
] as unknown as StepValue[];
const groups = mergePageSteps('p1', list, 2);
expect(groups).toHaveLength(2);
expect(groups[0].opType).toBe('add');
expect(groups[1].opType).toBe('update');
});
test('重命名时 targetName 取最近一次', () => {
const list = [
{
opType: 'update',
diff: [{ newSchema: { id: 'n1', name: '旧名' }, oldSchema: { id: 'n1', name: '旧名' } }],
},
{
opType: 'update',
diff: [{ newSchema: { id: 'n1', name: '新名' }, oldSchema: { id: 'n1', name: '旧名' } }],
},
] as unknown as StepValue[];
const groups = mergePageSteps('p1', list, 2);
expect(groups[0].targetName).toBe('新名');
});
});
describe('serializeStacks / deserializeStacks', () => {
test('序列化后还原栈内容与游标', () => {
const stacks = {
p1: new UndoRedo<{ v: number }>(),
};
stacks.p1.pushElement({ v: 1 });
stacks.p1.pushElement({ v: 2 });
const serialized = serializeStacks(stacks);
const restored = deserializeStacks(serialized);
expect(restored.p1.getLength()).toBe(2);
expect(restored.p1.getCursor()).toBe(2);
expect(restored.p1.getCurrentElement()).toEqual({ v: 2 });
});
test('还原时游标定位到最近一条 saved 记录之后', () => {
const stacks = {
p1: new UndoRedo<{ saved?: boolean; v: number }>(),
};
stacks.p1.pushElement({ v: 1 });
stacks.p1.pushElement({ v: 2, saved: true });
stacks.p1.pushElement({ v: 3 });
const restored = deserializeStacks(serializeStacks(stacks));
expect(restored.p1.getCursor()).toBe(2);
expect(restored.p1.getCurrentElement()).toEqual({ v: 2, saved: true });
});
test('空表与缺省参数', () => {
expect(serializeStacks({})).toEqual({});
expect(deserializeStacks()).toEqual({});
});
});
describe('getOrCreateStack', () => {
test('不存在时创建新栈', () => {
const stacks: Record<string, UndoRedo<number>> = {};
const stack = getOrCreateStack(stacks, 'a');
expect(stack).toBeInstanceOf(UndoRedo);
expect(stacks.a).toBe(stack);
});
test('已存在时返回原栈', () => {
const existing = new UndoRedo<number>();
existing.pushElement(1);
const stacks = { a: existing };
expect(getOrCreateStack(stacks, 'a')).toBe(existing);
expect(getOrCreateStack(stacks, 'a').getLength()).toBe(1);
});
});
describe('undoFloor', () => {
test('空栈返回 0', () => {
expect(undoFloor(new UndoRedo<StepValue>())).toBe(0);
});
test('无 initial 基线返回 0', () => {
const undoRedo = new UndoRedo<StepValue>();
undoRedo.pushElement({ opType: 'add', diff: [] } as unknown as StepValue);
expect(undoFloor(undoRedo)).toBe(0);
});
test('index 0 为 initial 时返回 1', () => {
const undoRedo = new UndoRedo<StepValue>();
undoRedo.pushElement({ opType: 'initial', diff: [] } as unknown as StepValue);
undoRedo.pushElement({ opType: 'update', diff: [] } as unknown as StepValue);
expect(undoFloor(undoRedo)).toBe(1);
});
});

View File

@ -81,7 +81,7 @@ const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourc
const { contentMenuData } = useEditorContentMenuData();
const editor = shallowRef<InstanceType<typeof TMagicEditor>>();
const value = ref<MApp>(dsl);
const value = ref<MApp>();
const defaultSelected = ref(dsl.items[0].id);
const stageRect = ref({
@ -148,20 +148,19 @@ const persistHistory = () => {
// /
const schedulePersist = debounce(persistHistory, 500);
// IndexedDB undo/redo
// IndexedDB root DSL id
// app select(defaultSelected) changePage
const restoreHistory = async () => {
try {
const snapshot = await historyService.restoreFromIndexedDB();
if (!snapshot) return;
const page = editorService.get('page');
if (page) historyService.changePage(page);
await historyService.restoreFromIndexedDB({ appId: value.value?.id });
} catch (error) {
console.error('恢复历史记录失败', error);
}
};
onMounted(() => {
restoreHistory();
onMounted(async () => {
await restoreHistory();
value.value = dsl;
historyService.on('change', schedulePersist);
historyService.on('code-block-history-change', schedulePersist);
historyService.on('data-source-history-change', schedulePersist);

View File

@ -9,7 +9,7 @@ import AdapterSelect from '../../components/AdapterSelect.vue';
import DeviceGroup from '../../components/DeviceGroup.vue';
import { uaMap } from '../../const';
export const useEditorMenu = (value: Ref<MApp>, save: () => void) => {
export const useEditorMenu = (value: Ref<MApp | undefined>, save: () => void) => {
const router = useRouter();
const deviceGroup = shallowRef<InstanceType<typeof DeviceGroup>>();