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 () => {