mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-18 05:11:59 +00:00
refactor(editor): 优化历史记录 IndexedDB 持久化,仅序列化 step diff
避免 serialize-javascript 序列化整份快照,改为结构化克隆写入 IndexedDB;初始历史行展示已保存标记;root-change 事件携带 options。
This commit is contained in:
parent
6960bd50e1
commit
921188d2da
@ -7,6 +7,7 @@
|
||||
<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">{{ desc }}</span>
|
||||
<span v-if="saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
|
||||
<span v-if="gotoEnabled && !isCurrent" class="m-editor-history-list-item-actions">
|
||||
<span class="m-editor-history-list-item-goto" title="回到该记录" @click.stop="onClick">回到</span>
|
||||
</span>
|
||||
@ -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(() => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<HistoryState>({
|
||||
@ -442,6 +442,8 @@ class History extends BaseService {
|
||||
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
|
||||
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<PersistedHistoryState | null> {
|
||||
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
||||
|
||||
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;
|
||||
const snapshot = await idbGet<PersistedHistoryState>(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;
|
||||
|
||||
|
||||
@ -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[]];
|
||||
|
||||
@ -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<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
|
||||
export const serializeStacks = <T>(stacks: Record<Id, UndoRedo<T>>) => {
|
||||
/**
|
||||
* 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。
|
||||
*
|
||||
* 序列化(深克隆)的同一趟里,只把每条 step 中可能含函数的 `diff` 用 serialize-javascript 序列化成字符串,
|
||||
* 其余字段(uuid / opType / timestamp / `modifiedNodeIds` Map 等)原样保留,交给 IndexedDB 结构化克隆。
|
||||
* 这样既能写入函数,又避免序列化整份快照的开销;读取时再由 {@link parseStacksStepDiff} 还原 diff。
|
||||
* 不含 `diff` 的元素(如通用栈)原样透传。
|
||||
*/
|
||||
export const serializeStacks = <T extends { diff?: unknown }>(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();
|
||||
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 = <T>(stacks: Record<Id, UndoRedo<T>>) => {
|
||||
/**
|
||||
* 把 `Record<Id, SerializedUndoRedo>` 整体还原为 `Record<Id, UndoRedo>`。
|
||||
* 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
|
||||
*
|
||||
* 与 {@link serializeStacks} 相反:当传入 `parse`(parseDSL)时,把每条 step 中以字符串形式存储的 `diff`
|
||||
* 解析回真实对象(含函数);不含 `diff` 的元素(如通用栈)原样透传。
|
||||
*/
|
||||
export const deserializeStacks = <T extends { saved?: boolean }>(
|
||||
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
|
||||
parse?: (serialized: string) => unknown,
|
||||
): 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 });
|
||||
}
|
||||
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<T>(
|
||||
{ ...serialized, elementList },
|
||||
{ isSavedStep: (element) => element.saved === true },
|
||||
);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user