@tmagic/editor 1.8.0-beta.3 → 1.8.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/es/components/CompareForm.vue_vue_type_script_setup_true_lang.js +15 -1
  2. package/dist/es/components/ToolButton.vue_vue_type_script_setup_true_lang.js +6 -6
  3. package/dist/es/hooks/use-code-block-edit.js +6 -3
  4. package/dist/es/hooks/use-data-source-edit.js +5 -2
  5. package/dist/es/hooks/use-stage.js +6 -3
  6. package/dist/es/index.js +6 -3
  7. package/dist/es/initService.js +1 -1
  8. package/dist/es/layouts/Framework.vue_vue_type_script_setup_true_lang.js +1 -1
  9. package/dist/es/layouts/NavMenu.vue_vue_type_script_setup_true_lang.js +1 -1
  10. package/dist/es/layouts/history-list/Bucket.vue_vue_type_script_setup_true_lang.js +19 -51
  11. package/dist/es/layouts/history-list/BucketTab.vue_vue_type_script_setup_true_lang.js +22 -36
  12. package/dist/es/layouts/history-list/GroupRow.vue_vue_type_script_setup_true_lang.js +152 -93
  13. package/dist/es/layouts/history-list/HistoryDiffDialog.vue_vue_type_script_setup_true_lang.js +36 -10
  14. package/dist/es/layouts/history-list/HistoryListPanel.vue_vue_type_script_setup_true_lang.js +334 -208
  15. package/dist/es/layouts/history-list/InitialRow.vue_vue_type_script_setup_true_lang.js +28 -7
  16. package/dist/es/layouts/history-list/PageTab.vue_vue_type_script_setup_true_lang.js +47 -57
  17. package/dist/es/layouts/history-list/composables.js +118 -25
  18. package/dist/es/layouts/page-bar/PageBar.vue_vue_type_script_setup_true_lang.js +4 -2
  19. package/dist/es/layouts/props-panel/PropsPanel.vue_vue_type_script_setup_true_lang.js +5 -1
  20. package/dist/es/layouts/sidebar/ComponentListPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  21. package/dist/es/layouts/sidebar/Sidebar.vue_vue_type_script_setup_true_lang.js +5 -1
  22. package/dist/es/layouts/sidebar/code-block/CodeBlockList.vue_vue_type_script_setup_true_lang.js +3 -3
  23. package/dist/es/layouts/sidebar/code-block/CodeBlockListPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  24. package/dist/es/layouts/sidebar/code-block/useContentMenu.js +1 -1
  25. package/dist/es/layouts/sidebar/data-source/DataSourceListPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  26. package/dist/es/layouts/sidebar/data-source/useContentMenu.js +1 -1
  27. package/dist/es/layouts/sidebar/layer/LayerMenu.vue_vue_type_script_setup_true_lang.js +5 -5
  28. package/dist/es/layouts/sidebar/layer/LayerNodeTool.vue_vue_type_script_setup_true_lang.js +1 -1
  29. package/dist/es/layouts/workspace/viewer/Stage.vue_vue_type_script_setup_true_lang.js +1 -1
  30. package/dist/es/layouts/workspace/viewer/ViewerMenu.vue_vue_type_script_setup_true_lang.js +8 -8
  31. package/dist/es/services/codeBlock.js +105 -42
  32. package/dist/es/services/dataSource.js +85 -35
  33. package/dist/es/services/editor.js +317 -122
  34. package/dist/es/services/history.js +270 -204
  35. package/dist/es/services/keybinding.js +3 -3
  36. package/dist/es/services/ui.js +3 -0
  37. package/dist/es/style.css +65 -10
  38. package/dist/es/utils/content-menu.js +17 -9
  39. package/dist/es/utils/editor.js +35 -1
  40. package/dist/es/utils/history.js +223 -0
  41. package/dist/es/utils/indexed-db.js +86 -0
  42. package/dist/es/utils/undo-redo.js +60 -1
  43. package/dist/style.css +65 -10
  44. package/dist/tmagic-editor.umd.cjs +2047 -954
  45. package/package.json +7 -7
  46. package/src/components/CompareForm.vue +21 -1
  47. package/src/components/ToolButton.vue +2 -2
  48. package/src/hooks/use-code-block-edit.ts +4 -3
  49. package/src/hooks/use-data-source-edit.ts +2 -2
  50. package/src/hooks/use-stage.ts +3 -3
  51. package/src/index.ts +1 -0
  52. package/src/initService.ts +1 -1
  53. package/src/layouts/Framework.vue +1 -1
  54. package/src/layouts/NavMenu.vue +1 -1
  55. package/src/layouts/history-list/Bucket.vue +34 -67
  56. package/src/layouts/history-list/BucketTab.vue +46 -51
  57. package/src/layouts/history-list/GroupRow.vue +150 -98
  58. package/src/layouts/history-list/HistoryDiffDialog.vue +96 -69
  59. package/src/layouts/history-list/HistoryListPanel.vue +298 -111
  60. package/src/layouts/history-list/InitialRow.vue +25 -9
  61. package/src/layouts/history-list/PageTab.vue +58 -48
  62. package/src/layouts/history-list/composables.ts +210 -29
  63. package/src/layouts/page-bar/PageBar.vue +4 -2
  64. package/src/layouts/props-panel/PropsPanel.vue +5 -1
  65. package/src/layouts/sidebar/ComponentListPanel.vue +9 -5
  66. package/src/layouts/sidebar/Sidebar.vue +6 -1
  67. package/src/layouts/sidebar/code-block/CodeBlockList.vue +5 -5
  68. package/src/layouts/sidebar/code-block/CodeBlockListPanel.vue +1 -1
  69. package/src/layouts/sidebar/code-block/useContentMenu.ts +1 -1
  70. package/src/layouts/sidebar/data-source/DataSourceListPanel.vue +1 -1
  71. package/src/layouts/sidebar/data-source/useContentMenu.ts +1 -1
  72. package/src/layouts/sidebar/layer/LayerMenu.vue +19 -11
  73. package/src/layouts/sidebar/layer/LayerNodeTool.vue +7 -4
  74. package/src/layouts/workspace/viewer/Stage.vue +1 -1
  75. package/src/layouts/workspace/viewer/ViewerMenu.vue +8 -8
  76. package/src/services/codeBlock.ts +129 -35
  77. package/src/services/dataSource.ts +105 -34
  78. package/src/services/editor.ts +470 -157
  79. package/src/services/history.ts +312 -203
  80. package/src/services/keybinding.ts +3 -3
  81. package/src/services/ui.ts +7 -0
  82. package/src/theme/history-list-panel.scss +87 -6
  83. package/src/theme/page-bar.scss +0 -4
  84. package/src/theme/props-panel.scss +3 -3
  85. package/src/type.ts +221 -61
  86. package/src/utils/content-menu.ts +18 -9
  87. package/src/utils/editor.ts +41 -1
  88. package/src/utils/history.ts +298 -0
  89. package/src/utils/index.ts +2 -0
  90. package/src/utils/indexed-db.ts +122 -0
  91. package/src/utils/undo-redo.ts +88 -0
  92. package/types/index.d.ts +871 -308
@@ -17,183 +17,49 @@
17
17
  */
18
18
 
19
19
  import { reactive } from 'vue';
20
- import { cloneDeep } from 'lodash-es';
20
+ import serialize from 'serialize-javascript';
21
21
 
22
22
  import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
23
23
  import type { ChangeRecord } from '@tmagic/form';
24
+ import { guid } from '@tmagic/utils';
24
25
 
25
26
  import type {
26
27
  CodeBlockHistoryGroup,
27
28
  CodeBlockStepValue,
28
29
  DataSourceHistoryGroup,
29
30
  DataSourceStepValue,
31
+ HistoryOpSource,
32
+ HistoryPersistOptions,
30
33
  HistoryState,
31
34
  PageHistoryGroup,
32
35
  PageHistoryStepEntry,
36
+ PersistedHistoryState,
33
37
  StepValue,
34
38
  } from '@editor/type';
39
+ import { getEditorConfig } from '@editor/utils/config';
40
+ import {
41
+ createStackStep,
42
+ deserializeStacks,
43
+ getOrCreateStack,
44
+ markStackSaved,
45
+ mergePageSteps,
46
+ mergeStackSteps,
47
+ serializeStacks,
48
+ undoFloor,
49
+ } from '@editor/utils/history';
50
+ import { idbGet, idbSet } from '@editor/utils/indexed-db';
35
51
  import { UndoRedo } from '@editor/utils/undo-redo';
36
52
 
37
53
  import BaseService from './BaseService';
54
+ import editorService from './editor';
38
55
 
39
- class History extends BaseService {
40
- /**
41
- * 把单个代码块栈拆成若干 group:
42
- * - 把"新增/删除"独立成组(语义上属于一次性事件,不应与 update 合并);
43
- * - 连续 'update' 合并到同一组,组内 steps 顺序就是发生顺序。
44
- */
45
- private static mergeCodeBlockSteps(
46
- codeBlockId: Id,
47
- list: CodeBlockStepValue[],
48
- cursor: number,
49
- ): CodeBlockHistoryGroup[] {
50
- const groups: CodeBlockHistoryGroup[] = [];
51
- let current: CodeBlockHistoryGroup | null = null;
52
- const currentIndex = cursor - 1;
53
- list.forEach((step, index) => {
54
- const opType = History.detectOpType(step.oldContent, step.newContent);
55
- const applied = index < cursor;
56
- const isCurrent = index === currentIndex;
57
- if (opType === 'update' && current?.opType === 'update') {
58
- current.steps.push({ step, index, applied, isCurrent });
59
- current.applied = applied;
60
- if (isCurrent) current.isCurrent = true;
61
- } else {
62
- current = {
63
- kind: 'code-block',
64
- id: codeBlockId,
65
- opType,
66
- steps: [{ step, index, applied, isCurrent }],
67
- applied,
68
- isCurrent,
69
- };
70
- groups.push(current);
71
- }
72
- });
73
- return groups;
74
- }
75
-
76
- private static mergeDataSourceSteps(
77
- dataSourceId: Id,
78
- list: DataSourceStepValue[],
79
- cursor: number,
80
- ): DataSourceHistoryGroup[] {
81
- const groups: DataSourceHistoryGroup[] = [];
82
- let current: DataSourceHistoryGroup | null = null;
83
- const currentIndex = cursor - 1;
84
- list.forEach((step, index) => {
85
- const opType = History.detectOpType(step.oldSchema, step.newSchema);
86
- const applied = index < cursor;
87
- const isCurrent = index === currentIndex;
88
- if (opType === 'update' && current?.opType === 'update') {
89
- current.steps.push({ step, index, applied, isCurrent });
90
- current.applied = applied;
91
- if (isCurrent) current.isCurrent = true;
92
- } else {
93
- current = {
94
- kind: 'data-source',
95
- id: dataSourceId,
96
- opType,
97
- steps: [{ step, index, applied, isCurrent }],
98
- applied,
99
- isCurrent,
100
- };
101
- groups.push(current);
102
- }
103
- });
104
- return groups;
105
- }
106
-
107
- /**
108
- * 根据 old/new 是否为 null 推断 opType(与 push 时的约定一致)。
109
- */
110
- private static detectOpType(oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' {
111
- if (oldVal === null && newVal !== null) return 'add';
112
- if (oldVal !== null && newVal === null) return 'remove';
113
- return 'update';
114
- }
115
-
116
- /**
117
- * 把页面栈拆成若干 group:
118
- * - 单节点的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group;
119
- * - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单节点修改组);
120
- * - 多节点 'update'(如批量改属性)也独立成组(无明确单一目标,避免误合并)。
121
- */
122
- private static mergePageSteps(pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] {
123
- const groups: PageHistoryGroup[] = [];
124
- let current: PageHistoryGroup | null = null;
125
- const currentIndex = cursor - 1;
126
- list.forEach((step, index) => {
127
- const applied = index < cursor;
128
- const isCurrent = index === currentIndex;
129
- const targetId = History.detectPageTargetId(step);
130
- const targetName = History.detectPageTargetName(step);
131
- const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
132
-
133
- // 仅"单节点 update"参与合并;其它情形(add/remove/多节点 update)始终独立成组。
134
- const mergeable = step.opType === 'update' && targetId !== undefined;
135
- if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
136
- current.steps.push(entry);
137
- current.applied = applied;
138
- if (isCurrent) current.isCurrent = true;
139
- // 保持目标名为最近一次的(节点重命名时也能反映)
140
- if (targetName) current.targetName = targetName;
141
- } else {
142
- current = {
143
- kind: 'page',
144
- pageId,
145
- opType: step.opType,
146
- targetId: mergeable ? targetId : undefined,
147
- targetName,
148
- steps: [entry],
149
- applied,
150
- isCurrent,
151
- };
152
- groups.push(current);
153
- }
154
- });
155
- return groups;
156
- }
157
-
158
- /**
159
- * 解析 StepValue 中的"目标节点 id"用于合并:
160
- * - 单节点 update:取唯一一项 updatedItems 的节点 id;
161
- * - 其它情形(多节点 update / add / remove):返回 undefined,表示不参与合并。
162
- */
163
- private static detectPageTargetId(step: StepValue): Id | undefined {
164
- if (step.opType !== 'update') return undefined;
165
- const items = step.updatedItems;
166
- if (items?.length !== 1) return undefined;
167
- return items[0].newNode?.id ?? items[0].oldNode?.id;
168
- }
169
-
170
- /** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
171
- private static detectPageTargetName(step: StepValue): string | undefined {
172
- if (step.opType === 'update') {
173
- const items = step.updatedItems;
174
- if (items?.length === 1) {
175
- const node = items[0].newNode || items[0].oldNode;
176
- return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
177
- }
178
- return items?.length ? `${items.length} 个节点` : undefined;
179
- }
180
- if (step.opType === 'add') {
181
- if (step.nodes?.length === 1) {
182
- const n = step.nodes[0];
183
- return (n.name as string) || (n.type as string) || `${n.id}`;
184
- }
185
- return step.nodes?.length ? `${step.nodes.length} 个节点` : undefined;
186
- }
187
- if (step.opType === 'remove') {
188
- if (step.removedItems?.length === 1) {
189
- const n = step.removedItems[0].node;
190
- return (n.name as string) || (n.type as string) || `${n.id}`;
191
- }
192
- return step.removedItems?.length ? `${step.removedItems.length} 个节点` : undefined;
193
- }
194
- return undefined;
195
- }
56
+ /** 历史记录持久化快照的默认存储位置与结构版本。 */
57
+ const DEFAULT_DB_NAME = 'tmagic-editor';
58
+ const DEFAULT_STORE_NAME = 'history';
59
+ const DEFAULT_KEY: IDBValidKey = 'default';
60
+ const PERSIST_VERSION = 1;
196
61
 
62
+ class History extends BaseService {
197
63
  public state = reactive<HistoryState>({
198
64
  pageSteps: {},
199
65
  pageId: undefined,
@@ -245,6 +111,59 @@ class History extends BaseService {
245
111
  this.state.dataSourceState = {};
246
112
  }
247
113
 
114
+ /**
115
+ * 为指定页面 / 页面片种入一条「初始基线」记录(如加载 DSL 时的「初始 / 加载」基线)。
116
+ *
117
+ * 该记录是一条 `opType: 'initial'` 的 {@link StepValue},作为页面历史栈 **index 0 的固定底线**:
118
+ * - 它是一条真实入栈的 step(随栈一起持久化),但被钉为撤销/回滚的下限——cursor 永不低于它,
119
+ * 因此不会被 undo / goto / revert 触达(详见 {@link undo} / {@link setCanUndoRedo});
120
+ * - 历史面板把它过滤出分组列表(见 {@link getPageHistoryGroups}),改由底部「初始」行展示。
121
+ *
122
+ * 仅当目标页面栈为空时种入(保证 initial 一定位于 index 0);已存在 initial 底线时默认不重复种入,
123
+ * 传 `force=true` 且栈为空时按新基线种入。
124
+ */
125
+ public setPageMarker(
126
+ pageId: Id,
127
+ options: { name?: string; description?: string; source?: HistoryOpSource } = {},
128
+ ): StepValue | null {
129
+ if (pageId === undefined || pageId === null || `${pageId}` === '') return null;
130
+
131
+ const existing = this.getPageMarker(pageId);
132
+ if (existing) return existing;
133
+
134
+ const stack = getOrCreateStack(this.state.pageSteps, pageId);
135
+ // initial 必须是 index 0;栈非空(已有真实记录、却无 initial,如旧数据)时不强行前插,优雅降级为无基线。
136
+ if (stack.getLength() > 0) return null;
137
+
138
+ const marker: StepValue = {
139
+ uuid: guid(),
140
+ opType: 'initial',
141
+ diff: [],
142
+ data: { name: options.name || '', id: pageId },
143
+ selectedBefore: [],
144
+ selectedAfter: [],
145
+ modifiedNodeIds: new Map(),
146
+ historyDescription: options.description || '未修改的初始状态',
147
+ timestamp: Date.now(),
148
+ ...(options.source ? { source: options.source } : {}),
149
+ };
150
+ stack.pushElement(marker);
151
+ if (`${pageId}` === `${this.state.pageId}`) this.setCanUndoRedo();
152
+ this.emit('page-marker-change', marker);
153
+ return marker;
154
+ }
155
+
156
+ /**
157
+ * 读取指定页面(缺省当前活动页)的初始基线 step(页面栈 index 0 且 `opType: 'initial'`);
158
+ * 不存在时返回 undefined。
159
+ */
160
+ public getPageMarker(pageId?: Id): StepValue | undefined {
161
+ const targetPageId = pageId ?? this.state.pageId;
162
+ if (!targetPageId) return undefined;
163
+ const first = this.state.pageSteps[targetPageId]?.getElementList()[0];
164
+ return first?.opType === 'initial' ? first : undefined;
165
+ }
166
+
248
167
  /**
249
168
  * 把一条步骤推入指定页面的栈;不指定 pageId 时落到当前活动页。
250
169
  *
@@ -254,6 +173,7 @@ class History extends BaseService {
254
173
  public push(state: StepValue, pageId?: Id): StepValue | null {
255
174
  const undoRedo = this.getUndoRedo(pageId);
256
175
  if (!undoRedo) return null;
176
+ if (state.uuid === undefined) state.uuid = guid();
257
177
  if (state.timestamp === undefined) state.timestamp = Date.now();
258
178
  undoRedo.pushElement(state);
259
179
  // 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。
@@ -263,6 +183,27 @@ class History extends BaseService {
263
183
  return state;
264
184
  }
265
185
 
186
+ /** 读取指定页面(缺省当前活动页)历史栈当前游标所在的 step(cursor - 1);无则返回 null。 */
187
+ public getCurrentPageStep(pageId?: Id): StepValue | null {
188
+ const targetPageId = pageId ?? this.state.pageId;
189
+ if (!targetPageId) return null;
190
+ return this.state.pageSteps[targetPageId]?.getCurrentElement() ?? null;
191
+ }
192
+
193
+ /**
194
+ * 用 `state` 替换指定页面栈当前游标所在的 step(并丢弃其后的重做尾部),游标不变。
195
+ * 用于「连续 set root 记录合并」等就地替换最新一条的场景;替换成功后按需刷新 / 通知。
196
+ */
197
+ public replaceCurrentPageStep(state: StepValue, pageId?: Id): StepValue | null {
198
+ const undoRedo = this.getUndoRedo(pageId);
199
+ if (!undoRedo) return null;
200
+ if (state.uuid === undefined) state.uuid = guid();
201
+ if (state.timestamp === undefined) state.timestamp = Date.now();
202
+ if (!undoRedo.replaceCurrentElement(state)) return null;
203
+ this.emit('change', state);
204
+ return state;
205
+ }
206
+
266
207
  /**
267
208
  * 推入一条代码块变更记录(与页面/节点完全无关),按 `codeBlockId` 维度独立一份 UndoRedo 栈。
268
209
  *
@@ -280,20 +221,19 @@ class History extends BaseService {
280
221
  changeRecords?: ChangeRecord[];
281
222
  /** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */
282
223
  historyDescription?: string;
224
+ /** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
225
+ source?: HistoryOpSource;
283
226
  },
284
227
  ): CodeBlockStepValue | null {
285
- if (!codeBlockId) return null;
286
-
287
- const step: CodeBlockStepValue = {
288
- id: codeBlockId,
289
- oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null,
290
- newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
291
- changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
228
+ const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
229
+ oldValue: payload.oldContent,
230
+ newValue: payload.newContent,
231
+ changeRecords: payload.changeRecords,
292
232
  historyDescription: payload.historyDescription,
293
- timestamp: Date.now(),
294
- };
295
-
296
- this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
233
+ source: payload.source,
234
+ });
235
+ if (!step) return null;
236
+ getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step);
297
237
  this.emit('code-block-history-change', codeBlockId, step);
298
238
  return step;
299
239
  }
@@ -310,20 +250,19 @@ class History extends BaseService {
310
250
  changeRecords?: ChangeRecord[];
311
251
  /** 可选的人类可读描述,仅用于历史面板展示。 */
312
252
  historyDescription?: string;
253
+ /** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
254
+ source?: HistoryOpSource;
313
255
  },
314
256
  ): DataSourceStepValue | null {
315
- if (!dataSourceId) return null;
316
-
317
- const step: DataSourceStepValue = {
318
- id: dataSourceId,
319
- oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null,
320
- newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
321
- changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
257
+ const step = createStackStep<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
258
+ oldValue: payload.oldSchema,
259
+ newValue: payload.newSchema,
260
+ changeRecords: payload.changeRecords,
322
261
  historyDescription: payload.historyDescription,
323
- timestamp: Date.now(),
324
- };
325
-
326
- this.getDataSourceUndoRedo(dataSourceId).pushElement(step);
262
+ source: payload.source,
263
+ });
264
+ if (!step) return null;
265
+ getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step);
327
266
  this.emit('data-source-history-change', dataSourceId, step);
328
267
  return step;
329
268
  }
@@ -387,6 +326,8 @@ class History extends BaseService {
387
326
  public undo(): StepValue | null {
388
327
  const undoRedo = this.getUndoRedo();
389
328
  if (!undoRedo) return null;
329
+ // 不允许撤销越过初始基线(index 0 的 initial step)。
330
+ if (undoRedo.getCursor() <= undoFloor(undoRedo)) return null;
390
331
  const state = undoRedo.undo();
391
332
  this.emit('change', state);
392
333
  return state;
@@ -406,6 +347,146 @@ class History extends BaseService {
406
347
  this.removeAllPlugins();
407
348
  }
408
349
 
350
+ /**
351
+ * 清空指定页面(缺省当前活动页)的历史记录栈。
352
+ * 仅删除撤销/重做记录,不会改动当前 DSL;清空后该页将无法再撤销/重做之前的操作。
353
+ */
354
+ public clearPage(pageId?: Id): void {
355
+ const targetPageId = pageId ?? this.state.pageId;
356
+ if (!targetPageId) return;
357
+ // 保留该页原 initial 基线的文案 / 来源(仅清空其后的真实操作记录),无基线时清空成空栈。
358
+ const marker = this.getPageMarker(targetPageId);
359
+ this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
360
+ if (marker) {
361
+ this.setPageMarker(targetPageId, {
362
+ name: marker.data?.name,
363
+ description: marker.historyDescription,
364
+ source: marker.source,
365
+ });
366
+ }
367
+ if (`${targetPageId}` === `${this.state.pageId}`) {
368
+ this.setCanUndoRedo();
369
+ this.emit('clear-page', null);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * 清空数据源历史记录栈:传入 `dataSourceId` 仅清空该数据源,缺省清空全部数据源。
375
+ * 仅删除撤销/重做记录,不会改动数据源本身。
376
+ */
377
+ public clearDataSource(dataSourceId?: Id): void {
378
+ if (dataSourceId !== undefined) {
379
+ delete this.state.dataSourceState[dataSourceId];
380
+ } else {
381
+ this.state.dataSourceState = {};
382
+ }
383
+ }
384
+
385
+ /**
386
+ * 清空代码块历史记录栈:传入 `codeBlockId` 仅清空该代码块,缺省清空全部代码块。
387
+ * 仅删除撤销/重做记录,不会改动代码块本身。
388
+ */
389
+ public clearCodeBlock(codeBlockId?: Id): void {
390
+ if (codeBlockId !== undefined) {
391
+ delete this.state.codeBlockState[codeBlockId];
392
+ } else {
393
+ this.state.codeBlockState = {};
394
+ }
395
+ }
396
+
397
+ /**
398
+ * 标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标为 `saved`。
399
+ * 适用于「整体落库」场景;若只保存了其中一类,请改用更细粒度的
400
+ * {@link markPageSaved} / {@link markCodeBlockSaved} / {@link markDataSourceSaved}。
401
+ */
402
+ public markSaved(): void {
403
+ Object.values(this.state.pageSteps).forEach(markStackSaved);
404
+ Object.values(this.state.codeBlockState).forEach(markStackSaved);
405
+ Object.values(this.state.dataSourceState).forEach(markStackSaved);
406
+ this.emit('mark-saved', { kind: 'all' });
407
+ }
408
+
409
+ /**
410
+ * 标记指定页面(缺省为当前活动页)的历史栈当前记录为已保存。
411
+ * 仅影响该页面自己的栈,不波及代码块 / 数据源 / 其它页面。
412
+ */
413
+ public markPageSaved(pageId?: Id): void {
414
+ const targetPageId = pageId ?? this.state.pageId;
415
+ if (!targetPageId) return;
416
+ markStackSaved(this.state.pageSteps[targetPageId]);
417
+ this.emit('mark-saved', { kind: 'page', id: targetPageId });
418
+ }
419
+
420
+ /** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */
421
+ public markCodeBlockSaved(codeBlockId: Id): void {
422
+ if (!codeBlockId) return;
423
+ markStackSaved(this.state.codeBlockState[codeBlockId]);
424
+ this.emit('mark-saved', { kind: 'code-block', id: codeBlockId });
425
+ }
426
+
427
+ /** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */
428
+ public markDataSourceSaved(dataSourceId: Id): void {
429
+ if (!dataSourceId) return;
430
+ markStackSaved(this.state.dataSourceState[dataSourceId]);
431
+ this.emit('mark-saved', { kind: 'data-source', id: dataSourceId });
432
+ }
433
+
434
+ /**
435
+ * 把当前内存中的全部历史栈(页面 / 代码块 / 数据源)序列化后写入本地 IndexedDB。
436
+ *
437
+ * - 每个 UndoRedo 栈连同其游标、容量一并保存,恢复后可继续 undo/redo;
438
+ * - `key` 用于区分不同活动页 / 项目(同一 store 下可保存多份快照),缺省为 `default`;
439
+ * - 返回写入成功的快照对象,便于调用方记录 savedAt 等信息;
440
+ * - 不支持 IndexedDB 的环境(如 SSR)会 reject。
441
+ */
442
+ public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
443
+ const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
444
+
445
+ const snapshot: PersistedHistoryState = {
446
+ version: PERSIST_VERSION,
447
+ pageId: this.state.pageId,
448
+ pageSteps: serializeStacks(this.state.pageSteps),
449
+ codeBlockState: serializeStacks(this.state.codeBlockState),
450
+ dataSourceState: serializeStacks(this.state.dataSourceState),
451
+ savedAt: Date.now(),
452
+ };
453
+
454
+ // 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法),IndexedDB 的结构化克隆无法写入函数,
455
+ // 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
456
+ await idbSet(this.resolveDbName(dbName, appId), storeName, key, serialize(snapshot));
457
+ this.emit('save-to-indexed-db', snapshot);
458
+ return snapshot;
459
+ }
460
+
461
+ /**
462
+ * 从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。
463
+ *
464
+ * - 读取到的每个栈都会经 {@link UndoRedo.fromSerialized} 还原(含游标),随后可直接 undo/redo;
465
+ * - 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 pageId;
466
+ * - 找不到对应记录时返回 null,且不改动当前状态;
467
+ * - 不支持 IndexedDB 的环境(如 SSR)会 reject。
468
+ */
469
+ public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
470
+ const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
471
+
472
+ const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName, appId), storeName, key);
473
+ if (!raw) return null;
474
+
475
+ // 新版以序列化字符串存储(含函数),用 parseDSL 还原;兼容历史上以对象形式存入的旧数据。
476
+ const snapshot = (typeof raw === 'string' ? getEditorConfig('parseDSL')(`(${raw})`) : raw) as PersistedHistoryState;
477
+ if (!snapshot) return null;
478
+
479
+ this.state.pageSteps = deserializeStacks(snapshot.pageSteps);
480
+ this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState);
481
+ this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState);
482
+ // initial 基线作为页面栈 index 0 的 step 随 pageSteps 一并还原,无需单独恢复。
483
+ this.state.pageId = snapshot.pageId;
484
+
485
+ this.setCanUndoRedo();
486
+ this.emit('restore-from-indexed-db', snapshot);
487
+ return snapshot;
488
+ }
489
+
409
490
  /**
410
491
  * 取出当前活动页的历史步骤平铺列表(包含已应用 + 已撤销)。
411
492
  * 列表按时间正序,最早一步在最前面。
@@ -439,7 +520,9 @@ class History extends BaseService {
439
520
  const list = undoRedo.getElementList();
440
521
  if (!list.length) return [];
441
522
  const cursor = undoRedo.getCursor();
442
- return History.mergePageSteps(targetPageId, list, cursor);
523
+ // initial 基线(index 0)不作为普通操作组展示,过滤掉;其余真实 step 的 index 保持不变,
524
+ // 以便面板 goto(index+1) / revert(index) 仍直接对应栈内位置。底部「初始」行由 getPageMarker 驱动。
525
+ return mergePageSteps(targetPageId, list, cursor).filter((group) => group.opType !== 'initial');
443
526
  }
444
527
 
445
528
  /**
@@ -456,7 +539,7 @@ class History extends BaseService {
456
539
  const list = undoRedo.getElementList();
457
540
  if (!list.length) return;
458
541
  const cursor = undoRedo.getCursor();
459
- groups.push(...History.mergeCodeBlockSteps(id, list, cursor));
542
+ groups.push(...mergeStackSteps('code-block', id, list, cursor));
460
543
  });
461
544
  return groups;
462
545
  }
@@ -503,6 +586,41 @@ class History extends BaseService {
503
586
  return list.map((step, index) => ({ step, index, applied: index < cursor }));
504
587
  }
505
588
 
589
+ /**
590
+ * 按历史记录 uuid 在指定页面(默认当前活动页)的栈中查找其索引。
591
+ * 找不到时返回 -1。供「按 uuid 回滚」等需要把 uuid 映射回 index 的场景使用。
592
+ */
593
+ public getPageStepIndexByUuid(uuid: string, pageId?: Id): number {
594
+ if (!uuid) return -1;
595
+ return this.getPageStepList(pageId).findIndex((entry) => entry.step.uuid === uuid);
596
+ }
597
+
598
+ /**
599
+ * 按历史记录 uuid 在全部代码块栈中查找其所属 codeBlockId 与索引。
600
+ * 找不到时返回 null。
601
+ */
602
+ public findCodeBlockStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
603
+ if (!uuid) return null;
604
+ for (const id of Object.keys(this.state.codeBlockState)) {
605
+ const index = this.getCodeBlockStepList(id).findIndex((entry) => entry.step.uuid === uuid);
606
+ if (index >= 0) return { id, index };
607
+ }
608
+ return null;
609
+ }
610
+
611
+ /**
612
+ * 按历史记录 uuid 在全部数据源栈中查找其所属 dataSourceId 与索引。
613
+ * 找不到时返回 null。
614
+ */
615
+ public findDataSourceStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
616
+ if (!uuid) return null;
617
+ for (const id of Object.keys(this.state.dataSourceState)) {
618
+ const index = this.getDataSourceStepList(id).findIndex((entry) => entry.step.uuid === uuid);
619
+ if (index >= 0) return { id, index };
620
+ }
621
+ return null;
622
+ }
623
+
506
624
  /**
507
625
  * 取出全部数据源的历史栈,按 dataSourceId 分组。同上。
508
626
  */
@@ -513,7 +631,7 @@ class History extends BaseService {
513
631
  const list = undoRedo.getElementList();
514
632
  if (!list.length) return;
515
633
  const cursor = undoRedo.getCursor();
516
- groups.push(...History.mergeDataSourceSteps(id, list, cursor));
634
+ groups.push(...mergeStackSteps('data-source', id, list, cursor));
517
635
  });
518
636
  return groups;
519
637
  }
@@ -533,30 +651,21 @@ class History extends BaseService {
533
651
  return this.state.pageSteps[targetPageId];
534
652
  }
535
653
 
536
- private setCanUndoRedo(): void {
537
- const undoRedo = this.getUndoRedo();
538
- this.state.canRedo = undoRedo?.canRedo() || false;
539
- this.state.canUndo = undoRedo?.canUndo() || false;
540
- }
541
-
542
654
  /**
543
- * id 获取(或创建)指定代码块的 UndoRedo 栈。
655
+ * 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。
656
+ * 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
544
657
  */
545
- private getCodeBlockUndoRedo(codeBlockId: Id): UndoRedo<CodeBlockStepValue> {
546
- if (!this.state.codeBlockState[codeBlockId]) {
547
- this.state.codeBlockState[codeBlockId] = new UndoRedo<CodeBlockStepValue>();
548
- }
549
- return this.state.codeBlockState[codeBlockId];
658
+ private resolveDbName(dbName: string, appId?: Id): string {
659
+ // 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
660
+ const resolvedAppId = appId ?? editorService.get('root')?.id;
661
+ return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
550
662
  }
551
663
 
552
- /**
553
- * id 获取(或创建)指定数据源的 UndoRedo 栈。
554
- */
555
- private getDataSourceUndoRedo(dataSourceId: Id): UndoRedo<DataSourceStepValue> {
556
- if (!this.state.dataSourceState[dataSourceId]) {
557
- this.state.dataSourceState[dataSourceId] = new UndoRedo<DataSourceStepValue>();
558
- }
559
- return this.state.dataSourceState[dataSourceId];
664
+ private setCanUndoRedo(): void {
665
+ const undoRedo = this.getUndoRedo();
666
+ this.state.canRedo = undoRedo?.canRedo() || false;
667
+ // 初始基线之上才可撤销:cursor 必须高于底线(有 initial 时为 1)。
668
+ this.state.canUndo = undoRedo ? undoRedo.getCursor() > undoFloor(undoRedo) : false;
560
669
  }
561
670
  }
562
671
 
@@ -20,7 +20,7 @@ class Keybinding extends BaseService {
20
20
  const nodes = editorService.get('nodes');
21
21
 
22
22
  if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
23
- editorService.remove(nodes);
23
+ editorService.remove(nodes, { historySource: 'shortcut' });
24
24
  },
25
25
  [KeyBindingCommand.COPY_NODE]: () => {
26
26
  const nodes = editorService.get('nodes');
@@ -31,11 +31,11 @@ class Keybinding extends BaseService {
31
31
 
32
32
  if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
33
33
  editorService.copy(nodes);
34
- editorService.remove(nodes);
34
+ editorService.remove(nodes, { historySource: 'shortcut' });
35
35
  },
36
36
  [KeyBindingCommand.PASTE_NODE]: () => {
37
37
  const nodes = editorService.get('nodes');
38
- nodes && editorService.paste({ offsetX: 10, offsetY: 10 });
38
+ nodes && editorService.paste({ offsetX: 10, offsetY: 10 }, undefined, { historySource: 'shortcut' });
39
39
  },
40
40
  [KeyBindingCommand.UNDO]: () => {
41
41
  editorService.undo();
@@ -62,6 +62,7 @@ const state = shallowReactive<UiState>({
62
62
  showPageListButton: true,
63
63
  hideSlideBar: false,
64
64
  sideBarItems: [],
65
+ sideBarActiveTabName: '',
65
66
  navMenuRect: {
66
67
  left: 0,
67
68
  top: 0,
@@ -104,7 +105,13 @@ class Ui extends BaseService {
104
105
  mask?.showRule(value as unknown as boolean);
105
106
  }
106
107
 
108
+ const preValue = state[name];
109
+
107
110
  state[name] = value;
111
+
112
+ if (preValue !== value) {
113
+ this.emit('state-change', name, value, preValue);
114
+ }
108
115
  }
109
116
 
110
117
  public get<K extends keyof UiState>(name: K) {