diff --git a/packages/editor/src/layouts/history-list/InitialRow.vue b/packages/editor/src/layouts/history-list/InitialRow.vue
index abbea856..1f30a4ee 100644
--- a/packages/editor/src/layouts/history-list/InitialRow.vue
+++ b/packages/editor/src/layouts/history-list/InitialRow.vue
@@ -7,6 +7,7 @@
#0
初始
{{ desc }}
+ 已保存
回到
@@ -49,6 +50,8 @@ const props = withDefaults(
);
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
+/** 基线(初始状态)是否为最近一次保存点:仅页面栈的 `initial` 基线 step 会被 markSaved 标记。 */
+const saved = computed(() => Boolean(props.marker?.saved));
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
const rowTitle = computed(() => {
diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts
index 5750a65c..1f837b34 100644
--- a/packages/editor/src/services/editor.ts
+++ b/packages/editor/src/services/editor.ts
@@ -179,7 +179,7 @@ class Editor extends BaseService {
this.state.stageLoading = false;
}
- this.emit('root-change', value as StoreState['root'], preValue as StoreState['root']);
+ this.emit('root-change', value as StoreState['root'], preValue as StoreState['root'], options);
}
}
diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts
index 062dd7a8..abb465ff 100644
--- a/packages/editor/src/services/history.ts
+++ b/packages/editor/src/services/history.ts
@@ -17,7 +17,6 @@
*/
import { reactive } from 'vue';
-import serialize from 'serialize-javascript';
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
@@ -57,7 +56,8 @@ import editorService from './editor';
const DEFAULT_DB_NAME = 'tmagic-editor';
const DEFAULT_STORE_NAME = 'history';
const DEFAULT_KEY: IDBValidKey = 'default';
-const PERSIST_VERSION = 1;
+// v2:仅把每条 step 的 diff 序列化成字符串,其余字段交给 IndexedDB 结构化克隆原生存储(见 saveToIndexedDB)。
+const PERSIST_VERSION = 2;
class History extends BaseService {
public state = reactive({
@@ -442,6 +442,8 @@ class History extends BaseService {
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
+ // serializeStacks 会在序列化各栈时只把每条 step 的 diff(可能含函数)序列化成字符串,其余字段原样保留,
+ // 因此整份快照可直接按对象写入 IndexedDB(结构化克隆),避免序列化整份快照的开销;读取时再用 parseDSL 还原 diff。
const snapshot: PersistedHistoryState = {
version: PERSIST_VERSION,
pageId: this.state.pageId,
@@ -451,9 +453,7 @@ class History extends BaseService {
savedAt: Date.now(),
};
- // 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法),IndexedDB 的结构化克隆无法写入函数,
- // 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
- await idbSet(this.resolveDbName(dbName, appId), storeName, key, serialize(snapshot));
+ await idbSet(this.resolveDbName(dbName, appId), storeName, key, snapshot);
this.emit('save-to-indexed-db', snapshot);
return snapshot;
}
@@ -469,16 +469,14 @@ class History extends BaseService {
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
- 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;
+ const snapshot = await idbGet(this.resolveDbName(dbName, appId), storeName, key);
if (!snapshot) return null;
- this.state.pageSteps = deserializeStacks(snapshot.pageSteps);
- this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState);
- this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState);
+ // 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。
+ const parseDSL = getEditorConfig('parseDSL');
+ this.state.pageSteps = deserializeStacks(snapshot.pageSteps, parseDSL);
+ this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState, parseDSL);
+ this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState, parseDSL);
// initial 基线作为页面栈 index 0 的 step 随 pageSteps 一并还原,无需单独恢复。
this.state.pageId = snapshot.pageId;
diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts
index 60d303c6..9f298f42 100644
--- a/packages/editor/src/type.ts
+++ b/packages/editor/src/type.ts
@@ -1189,7 +1189,11 @@ export type CustomContentMenuFunction = (
) => (MenuButton | MenuComponent)[];
export interface EditorEvents {
- 'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
+ 'root-change': [
+ value: StoreState['root'],
+ preValue?: StoreState['root'],
+ options?: { historySource?: HistoryOpSource },
+ ];
select: [node: MNode | null];
add: [nodes: MNode[]];
remove: [nodes: MNode[]];
diff --git a/packages/editor/src/utils/history.ts b/packages/editor/src/utils/history.ts
index 90137df9..fe01ba4b 100644
--- a/packages/editor/src/utils/history.ts
+++ b/packages/editor/src/utils/history.ts
@@ -17,6 +17,7 @@
*/
import { cloneDeep } from 'lodash-es';
+import serialize from 'serialize-javascript';
import type { Id } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
@@ -234,11 +235,25 @@ export const detectPageTargetName = (step: StepValue): string | undefined => {
return undefined;
};
-/** 把 `Record` 整体序列化为 `Record`。 */
-export const serializeStacks = (stacks: Record>) => {
+/**
+ * 把 `Record` 整体序列化为 `Record`。
+ *
+ * 序列化(深克隆)的同一趟里,只把每条 step 中可能含函数的 `diff` 用 serialize-javascript 序列化成字符串,
+ * 其余字段(uuid / opType / timestamp / `modifiedNodeIds` Map 等)原样保留,交给 IndexedDB 结构化克隆。
+ * 这样既能写入函数,又避免序列化整份快照的开销;读取时再由 {@link parseStacksStepDiff} 还原 diff。
+ * 不含 `diff` 的元素(如通用栈)原样透传。
+ */
+export const serializeStacks = (stacks: Record>) => {
const result: Record['serialize']>> = {};
Object.entries(stacks).forEach(([id, undoRedo]) => {
- if (undoRedo) result[id] = undoRedo.serialize();
+ if (!undoRedo) return;
+ const serialized = undoRedo.serialize();
+ result[id] = {
+ ...serialized,
+ elementList: serialized.elementList.map((step) =>
+ step.diff === undefined ? step : Object.assign({}, step, { diff: serialize(step.diff) }),
+ ),
+ };
});
return result;
};
@@ -246,15 +261,27 @@ export const serializeStacks = (stacks: Record>) => {
/**
* 把 `Record` 整体还原为 `Record`。
* 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
+ *
+ * 与 {@link serializeStacks} 相反:当传入 `parse`(parseDSL)时,把每条 step 中以字符串形式存储的 `diff`
+ * 解析回真实对象(含函数);不含 `diff` 的元素(如通用栈)原样透传。
*/
export const deserializeStacks = (
stacks: Record['serialize']>> = {},
+ parse?: (serialized: string) => unknown,
): Record> => {
const result: Record> = {};
Object.entries(stacks).forEach(([id, serialized]) => {
- if (serialized) {
- result[id] = UndoRedo.fromSerialized(serialized, { isSavedStep: (element) => element.saved === true });
- }
+ if (!serialized) return;
+ const elementList = parse
+ ? serialized.elementList.map((step) => {
+ const { diff } = step as { diff?: unknown };
+ return typeof diff === 'string' ? Object.assign({}, step, { diff: parse(`(${diff})`) }) : step;
+ })
+ : serialized.elementList;
+ result[id] = UndoRedo.fromSerialized(
+ { ...serialized, elementList },
+ { isSavedStep: (element) => element.saved === true },
+ );
});
return result;
};
diff --git a/packages/editor/tests/unit/services/history-persist.spec.ts b/packages/editor/tests/unit/services/history-persist.spec.ts
index a91135bd..e4a91262 100644
--- a/packages/editor/tests/unit/services/history-persist.spec.ts
+++ b/packages/editor/tests/unit/services/history-persist.spec.ts
@@ -123,17 +123,23 @@ describe('history service - clear', () => {
});
describe('history service - IndexedDB 持久化', () => {
- test('saveToIndexedDB 以序列化字符串写入并返回快照对象', async () => {
+ test('saveToIndexedDB 以对象写入(仅 step.diff 序列化成字符串)并返回快照对象', async () => {
history.changePage({ id: 'p1' } as any);
- history.push(pageStep());
+ history.push({ ...pageStep(), diff: [{ newSchema: { id: 'n1', name: '节点' } }] } as any);
const snapshot = await history.saveToIndexedDB();
- expect(snapshot.version).toBe(1);
+ expect(snapshot.version).toBe(2);
expect(snapshot.pageId).toBe('p1');
- // 实际写入 IndexedDB 的是字符串(serialize-javascript 结果)
+ // 实际写入 IndexedDB 的是对象(交给结构化克隆),仅每条 step 的 diff 被序列化成字符串
expect(indexedDb.idbSet).toHaveBeenCalled();
const written = (indexedDb.idbSet as any).mock.calls[0][3];
- expect(typeof written).toBe('string');
+ expect(typeof written).toBe('object');
+ expect(typeof written.pageSteps.p1.elementList[0].diff).toBe('string');
+ // diff 之外的字段(如 modifiedNodeIds Map)原样交给结构化克隆,不被字符串化
+ expect(written.pageSteps.p1.elementList[0].modifiedNodeIds instanceof Map).toBe(true);
+ // 返回的快照即写入 IndexedDB 的持久化形态:diff 已是序列化字符串
+ expect(written).toBe(snapshot);
+ expect(typeof snapshot.pageSteps.p1.elementList[0].diff).toBe('string');
});
test('restoreFromIndexedDB 还原页面 / 代码块 / 数据源全部栈与游标', async () => {