diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 869fa4da..03f79f4a 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -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';
diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts
index 97059a6a..8065c730 100644
--- a/packages/editor/src/initService.ts
+++ b/packages/editor/src/initService.ts
@@ -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,
diff --git a/packages/editor/src/layouts/Framework.vue b/packages/editor/src/layouts/Framework.vue
index 8d6f4fd2..e5f2518a 100644
--- a/packages/editor/src/layouts/Framework.vue
+++ b/packages/editor/src/layouts/Framework.vue
@@ -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);
}
diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue
index 93baee46..b8ea1c6f 100644
--- a/packages/editor/src/layouts/history-list/Bucket.vue
+++ b/packages/editor/src/layouts/history-list/Bucket.vue
@@ -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 @@
diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts
index 3b2ea8dc..318e3ba1 100644
--- a/packages/editor/src/layouts/history-list/composables.ts
+++ b/packages/editor/src/layouts/history-list/composables.ts
@@ -11,6 +11,7 @@ import type {
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
+ HistoryRowDescriptor,
PageHistoryGroup,
StepValue,
} from '@editor/type';
@@ -30,37 +31,6 @@ export interface HistoryBucketGroup {
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
-/**
- * 一组「描述 + 可操作性」的判定函数集合。页面 / 数据源 / 代码块及业务自定义历史
- * 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。
- */
-export interface HistoryRowDescriptor {
- /** 组级描述文案生成器,接收一个 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 extends HistoryRowDescriptor {
- /** 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, 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>({});
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 = {
'component-panel': '组件面板',
props: '配置面板',
code: '源码',
+ 'root-code': 'DSL源码',
'stage-contextmenu': '画布菜单',
'tree-contextmenu': '树菜单',
toolbar: '工具栏',
@@ -218,6 +193,8 @@ const HISTORY_SOURCE_LABELS: Record = {
rollback: '回滚',
api: '接口',
ai: 'AI',
+ initial: '初始值',
+ sync: '同步',
unknown: '未知',
};
diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts
index ee69ba99..62b5a2b7 100644
--- a/packages/editor/src/services/editor.ts
+++ b/packages/editor/src/services/editor.ts
@@ -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(name: K, value: T) {
+ public set(
+ 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,
+ 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;
diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts
index 3257b815..957bc128 100644
--- a/packages/editor/src/services/history.ts
+++ b/packages/editor/src/services/history.ts
@@ -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(
- 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(undoRedo?: UndoRedo): void {
- if (!undoRedo) return;
- undoRedo.updateElements((element) => {
- element.saved = false;
- });
- undoRedo.updateCurrentElement((element) => {
- element.saved = true;
- });
- }
-
- /** 把 `Record` 整体序列化为 `Record`。 */
- private static serializeStacks(stacks: Record>) {
- const result: Record['serialize']>> = {};
- Object.entries(stacks).forEach(([id, undoRedo]) => {
- if (undoRedo) result[id] = undoRedo.serialize();
- });
- return result;
- }
-
- /**
- * 把 `Record` 整体还原为 `Record`。
- * 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
- */
- private static deserializeStacks(
- stacks: Record['serialize']>> = {},
- ): Record> {
- const result: Record> = {};
- Object.entries(stacks).forEach(([id, serialized]) => {
- if (serialized) {
- result[id] = UndoRedo.fromSerialized(serialized, { isSavedStep: (element) => element.saved === true });
- }
- });
- return result;
- }
-
- /**
- * 按 id 从「按 id 分栈」的记录表(代码块 / 数据源)中获取(或创建)对应的 UndoRedo 栈。
- */
- private static getOrCreateStack(stacks: Record>, id: Id): UndoRedo {
- if (!stacks[id]) {
- stacks[id] = new UndoRedo();
- }
- return stacks[id];
- }
-
public state = reactive({
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;
}
+ /** 读取指定页面(缺省当前活动页)历史栈当前游标所在的 step(cursor - 1);无则返回 null。 */
+ public getCurrentPageStep(pageId?: Id): StepValue | null {
+ const targetPageId = pageId ?? this.state.pageId;
+ if (!targetPageId) return null;
+ return this.state.pageSteps[targetPageId]?.getCurrentElement() ?? null;
+ }
+
+ /**
+ * 用 `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(codeBlockId, {
+ const step = createStackStep(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(dataSourceId, {
+ const step = createStackStep(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();
+ 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 {
- 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 {
- 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(this.resolveDbName(dbName), storeName, key);
+ const raw = await idbGet(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 与当前 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 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 & { 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 & { 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;
}
}
diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts
index 5e596165..088be110 100644
--- a/packages/editor/src/type.ts
+++ b/packages/editor/src/type.ts
@@ -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 {
* 同一栈内任意时刻最多只有一条记录为 true;从 IndexedDB 恢复时游标会被定位到最近一条已保存记录之后。
*/
saved?: boolean;
+ /**
+ * 是否为「整体设置 root」(set 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 {
+ /** 组级描述文案生成器,接收一个 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 extends HistoryRowDescriptor {
+ /** bucket 头部标题,例如 "数据源" / "代码块"。 */
+ title: string;
+ /** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
+ prefix: string;
+ /** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
+ showInitial?: boolean;
+ /** 是否支持「跳转到该记录」(goto),默认 true。 */
+ gotoEnabled?: boolean;
+}
diff --git a/packages/editor/src/utils/editor.ts b/packages/editor/src/utils/editor.ts
index 00444304..c5ad3f48 100644
--- a/packages/editor/src/utils/editor.ts
+++ b/packages/editor/src/utils/editor.ts
@@ -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} 个节点的修改`;
+ }
+ }
+};
diff --git a/packages/editor/src/utils/history.ts b/packages/editor/src/utils/history.ts
index f7c1b23f..f2d8985d 100644
--- a/packages/editor/src/utils/history.ts
+++ b/packages/editor/src/utils/history.ts
@@ -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 = (
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 = & { 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 & { 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 = (undoRedo?: UndoRedo): 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 = (
+ 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` 整体序列化为 `Record`。 */
+export const serializeStacks = (stacks: Record>) => {
+ const result: Record['serialize']>> = {};
+ Object.entries(stacks).forEach(([id, undoRedo]) => {
+ if (undoRedo) result[id] = undoRedo.serialize();
+ });
+ return result;
+};
+
+/**
+ * 把 `Record` 整体还原为 `Record`。
+ * 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
+ */
+export const deserializeStacks = (
+ stacks: Record['serialize']>> = {},
+): Record> => {
+ const result: Record> = {};
+ Object.entries(stacks).forEach(([id, serialized]) => {
+ if (serialized) {
+ result[id] = UndoRedo.fromSerialized(serialized, { isSavedStep: (element) => element.saved === true });
+ }
+ });
+ return result;
+};
+
+/**
+ * 按 id 从「按 id 分栈」的记录表(代码块 / 数据源)中获取(或创建)对应的 UndoRedo 栈。
+ */
+export const getOrCreateStack = (stacks: Record>, id: Id): UndoRedo => {
+ if (!stacks[id]) {
+ stacks[id] = new UndoRedo();
+ }
+ return stacks[id];
+};
+
+/**
+ * 撤销下限:当页面栈 index 0 是 `opType: 'initial'` 的基线 step 时为 1(基线不可被撤销),否则为 0。
+ * 用于把 cursor 钉在基线之上,保证 undo / canUndo / goto 都不会越过初始基线。
+ */
+export const undoFloor = (undoRedo: UndoRedo): number => {
+ return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
+};
diff --git a/packages/editor/src/utils/undo-redo.ts b/packages/editor/src/utils/undo-redo.ts
index b8d751ef..e9c55ced 100644
--- a/packages/editor/src/utils/undo-redo.ts
+++ b/packages/editor/src/utils/undo-redo.ts
@@ -139,6 +139,17 @@ export class UndoRedo {
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 - 1)做就地更新;cursor 为 0(全部已撤销)时不做任何操作。
* 用于给「当前步骤」打标记(如标记为已保存)等元数据写入场景。
diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts
index 7f85724b..f30d2d6b 100644
--- a/packages/editor/tests/unit/initService.spec.ts
+++ b/packages/editor/tests/unit/initService.spec.ts
@@ -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 设置', () => {
diff --git a/packages/editor/tests/unit/layouts/Framework.spec.ts b/packages/editor/tests/unit/layouts/Framework.spec.ts
index 442b95ba..ea8856b4 100644
--- a/packages/editor/tests/unit/layouts/Framework.spec.ts
+++ b/packages/editor/tests/unit/layouts/Framework.spec.ts
@@ -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 () => {
diff --git a/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts
index 254c1478..437a80f8 100644
--- a/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts
+++ b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts
@@ -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 () => {
diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts
index 3c169476..94dd327d 100644
--- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts
+++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts
@@ -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 () => {
diff --git a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts
index 680ef97d..688d2836 100644
--- a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts
+++ b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts
@@ -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);
});
diff --git a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts
index 6d42e07d..62a56079 100644
--- a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts
+++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts
@@ -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');
diff --git a/packages/editor/tests/unit/services/history-persist.spec.ts b/packages/editor/tests/unit/services/history-persist.spec.ts
index 2a251e2c..a91135bd 100644
--- a/packages/editor/tests/unit/services/history-persist.spec.ts
+++ b/packages/editor/tests/unit/services/history-persist.spec.ts
@@ -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' });
});
});
diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts
index f1b738b6..91c1eeea 100644
--- a/packages/editor/tests/unit/services/history.spec.ts
+++ b/packages/editor/tests/unit/services/history.spec.ts
@@ -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', () => {
diff --git a/packages/editor/tests/unit/utils/history.spec.ts b/packages/editor/tests/unit/utils/history.spec.ts
new file mode 100644
index 00000000..cbd48637
--- /dev/null
+++ b/packages/editor/tests/unit/utils/history.spec.ts
@@ -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> = {};
+ const stack = getOrCreateStack(stacks, 'a');
+ expect(stack).toBeInstanceOf(UndoRedo);
+ expect(stacks.a).toBe(stack);
+ });
+
+ test('已存在时返回原栈', () => {
+ const existing = new UndoRedo();
+ 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())).toBe(0);
+ });
+
+ test('无 initial 基线返回 0', () => {
+ const undoRedo = new UndoRedo();
+ undoRedo.pushElement({ opType: 'add', diff: [] } as unknown as StepValue);
+ expect(undoFloor(undoRedo)).toBe(0);
+ });
+
+ test('index 0 为 initial 时返回 1', () => {
+ const undoRedo = new UndoRedo();
+ undoRedo.pushElement({ opType: 'initial', diff: [] } as unknown as StepValue);
+ undoRedo.pushElement({ opType: 'update', diff: [] } as unknown as StepValue);
+ expect(undoFloor(undoRedo)).toBe(1);
+ });
+});
diff --git a/playground/src/pages/Editor.vue b/playground/src/pages/Editor.vue
index 052b26d7..58952b42 100644
--- a/playground/src/pages/Editor.vue
+++ b/playground/src/pages/Editor.vue
@@ -81,7 +81,7 @@ const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourc
const { contentMenuData } = useEditorContentMenuData();
const editor = shallowRef>();
-const value = ref(dsl);
+const value = ref();
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);
diff --git a/playground/src/pages/composables/use-editor-menu.ts b/playground/src/pages/composables/use-editor-menu.ts
index 4df09303..5afa859f 100644
--- a/playground/src/pages/composables/use-editor-menu.ts
+++ b/playground/src/pages/composables/use-editor-menu.ts
@@ -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, save: () => void) => {
+export const useEditorMenu = (value: Ref, save: () => void) => {
const router = useRouter();
const deviceGroup = shallowRef>();