@tmagic/editor 1.8.0-beta.4 → 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 (57) hide show
  1. package/dist/es/components/CompareForm.vue_vue_type_script_setup_true_lang.js +1 -1
  2. package/dist/es/components/ToolButton.vue_vue_type_script_setup_true_lang.js +6 -6
  3. package/dist/es/index.js +6 -3
  4. package/dist/es/initService.js +1 -1
  5. package/dist/es/layouts/Framework.vue_vue_type_script_setup_true_lang.js +1 -1
  6. package/dist/es/layouts/history-list/Bucket.vue_vue_type_script_setup_true_lang.js +19 -55
  7. package/dist/es/layouts/history-list/BucketTab.vue_vue_type_script_setup_true_lang.js +22 -39
  8. package/dist/es/layouts/history-list/GroupRow.vue_vue_type_script_setup_true_lang.js +149 -103
  9. package/dist/es/layouts/history-list/HistoryDiffDialog.vue_vue_type_script_setup_true_lang.js +32 -8
  10. package/dist/es/layouts/history-list/HistoryListPanel.vue_vue_type_script_setup_true_lang.js +333 -211
  11. package/dist/es/layouts/history-list/InitialRow.vue_vue_type_script_setup_true_lang.js +28 -7
  12. package/dist/es/layouts/history-list/PageTab.vue_vue_type_script_setup_true_lang.js +47 -60
  13. package/dist/es/layouts/history-list/composables.js +73 -32
  14. package/dist/es/layouts/page-bar/PageBar.vue_vue_type_script_setup_true_lang.js +4 -2
  15. package/dist/es/layouts/sidebar/Sidebar.vue_vue_type_script_setup_true_lang.js +5 -1
  16. package/dist/es/services/codeBlock.js +85 -37
  17. package/dist/es/services/dataSource.js +62 -26
  18. package/dist/es/services/editor.js +243 -100
  19. package/dist/es/services/history.js +270 -206
  20. package/dist/es/services/ui.js +3 -0
  21. package/dist/es/style.css +49 -6
  22. package/dist/es/utils/editor.js +35 -1
  23. package/dist/es/utils/history.js +223 -0
  24. package/dist/es/utils/indexed-db.js +86 -0
  25. package/dist/es/utils/undo-redo.js +60 -1
  26. package/dist/style.css +49 -6
  27. package/dist/tmagic-editor.umd.cjs +1799 -905
  28. package/package.json +7 -7
  29. package/src/components/CompareForm.vue +3 -1
  30. package/src/components/ToolButton.vue +2 -2
  31. package/src/index.ts +1 -0
  32. package/src/initService.ts +1 -1
  33. package/src/layouts/Framework.vue +1 -1
  34. package/src/layouts/history-list/Bucket.vue +34 -71
  35. package/src/layouts/history-list/BucketTab.vue +46 -54
  36. package/src/layouts/history-list/GroupRow.vue +146 -111
  37. package/src/layouts/history-list/HistoryDiffDialog.vue +94 -68
  38. package/src/layouts/history-list/HistoryListPanel.vue +296 -113
  39. package/src/layouts/history-list/InitialRow.vue +25 -9
  40. package/src/layouts/history-list/PageTab.vue +57 -51
  41. package/src/layouts/history-list/composables.ts +157 -36
  42. package/src/layouts/page-bar/PageBar.vue +4 -2
  43. package/src/layouts/sidebar/Sidebar.vue +6 -1
  44. package/src/services/codeBlock.ts +107 -37
  45. package/src/services/dataSource.ts +89 -40
  46. package/src/services/editor.ts +370 -134
  47. package/src/services/history.ts +305 -203
  48. package/src/services/ui.ts +7 -0
  49. package/src/theme/history-list-panel.scss +72 -5
  50. package/src/theme/page-bar.scss +0 -4
  51. package/src/type.ts +167 -63
  52. package/src/utils/editor.ts +41 -1
  53. package/src/utils/history.ts +298 -0
  54. package/src/utils/index.ts +2 -0
  55. package/src/utils/indexed-db.ts +122 -0
  56. package/src/utils/undo-redo.ts +88 -0
  57. package/types/index.d.ts +783 -291
@@ -17,13 +17,21 @@
17
17
  */
18
18
 
19
19
  import { reactive, toRaw } from 'vue';
20
- import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
20
+ import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
21
21
 
22
22
  import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
23
23
  import { NodeType } from '@tmagic/core';
24
24
  import type { ChangeRecord } from '@tmagic/form';
25
25
  import { isFixed } from '@tmagic/stage';
26
- import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils';
26
+ import {
27
+ getNodeInfo,
28
+ getNodePath,
29
+ getValueByKeyPath,
30
+ guid,
31
+ isPage,
32
+ isPageFragment,
33
+ setValueByKeyPath,
34
+ } from '@tmagic/utils';
27
35
 
28
36
  import BaseService from '@editor/services//BaseService';
29
37
  import propsService from '@editor/services//props';
@@ -39,6 +47,7 @@ import type {
39
47
  HistoryOpSource,
40
48
  HistoryOpType,
41
49
  PastePosition,
50
+ StepDiffItem,
42
51
  StepValue,
43
52
  StoreState,
44
53
  StoreStateKey,
@@ -51,6 +60,7 @@ import {
51
60
  classifyDragSources,
52
61
  collectRelatedNodes,
53
62
  COPY_STORAGE_KEY,
63
+ describeStepForRevert,
54
64
  editorNodeMergeCustomizer,
55
65
  fixNodePosition,
56
66
  getInitPositionStyle,
@@ -68,36 +78,18 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
68
78
  type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
69
79
 
70
80
  /**
71
- * 给「回滚」生成的新 step 用的简短描述生成器。
72
- * UI `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。
81
+ * 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
82
+ * `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
83
+ * 缺省(未传 / 空数组)才退化为整节点替换。
73
84
  */
74
- const describeStepForRevert = (step: StepValue): string => {
75
- switch (step.opType) {
76
- case 'add': {
77
- const count = step.nodes?.length ?? 0;
78
- const node = step.nodes?.[0];
79
- const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
80
- return `撤回新增 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
81
- }
82
- case 'remove': {
83
- const count = step.removedItems?.length ?? 0;
84
- const node = step.removedItems?.[0]?.node;
85
- const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
86
- return `还原已删除的 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
87
- }
88
- case 'update':
89
- default: {
90
- const items = step.updatedItems ?? [];
91
- if (items.length === 1) {
92
- const { newNode, oldNode, changeRecords } = items[0];
93
- const target = newNode?.name || newNode?.type || oldNode?.name || oldNode?.type || `${newNode?.id ?? ''}`;
94
- const propPath = changeRecords?.[0]?.propPath;
95
- return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
96
- }
97
- return `还原 ${items.length} 个节点的修改`;
98
- }
99
- }
100
- };
85
+ const buildUpdateDiff = (
86
+ items: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[],
87
+ ): StepDiffItem<MNode>[] =>
88
+ items.map(({ oldNode, newNode, changeRecords }) => ({
89
+ oldSchema: oldNode,
90
+ newSchema: newNode,
91
+ ...(changeRecords?.length ? { changeRecords } : {}),
92
+ }));
101
93
 
102
94
  class Editor extends BaseService {
103
95
  public state: StoreState = reactive({
@@ -116,6 +108,12 @@ class Editor extends BaseService {
116
108
  alwaysMultiSelect: false,
117
109
  });
118
110
  private selectionBeforeOp: Id[] | null = null;
111
+ /**
112
+ * 最近一次 pushOpHistory 写入的历史记录 uuid。
113
+ * 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id;
114
+ * 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
115
+ */
116
+ private lastPushedHistoryId: string | null = null;
119
117
 
120
118
  constructor() {
121
119
  super(
@@ -129,8 +127,13 @@ class Editor extends BaseService {
129
127
  * 设置当前指点节点配置
130
128
  * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
131
129
  * @param value MNode
130
+ * @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效)
132
131
  */
133
- public set<K extends StoreStateKey, T extends StoreState[K]>(name: K, value: T) {
132
+ public set<K extends StoreStateKey, T extends StoreState[K]>(
133
+ name: K,
134
+ value: T,
135
+ options: { historySource?: HistoryOpSource } = {},
136
+ ) {
134
137
  const preValue = this.state[name];
135
138
  this.state[name] = value;
136
139
 
@@ -149,6 +152,25 @@ class Editor extends BaseService {
149
152
  this.state.pageLength = getPageList(app).length || 0;
150
153
  this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
151
154
  this.state.stageLoading = this.state.pageLength !== 0;
155
+
156
+ if (preValue && !isEmpty(preValue)) {
157
+ // 编辑期间再次整体设置 root(源码保存 / 外部重设 DSL / root 节点更新):与上一次 root
158
+ // 做页面级 diff,按 update / add / remove 入栈,作为正常历史记录体现整体替换。
159
+ this.pushRootDiffHistory(preValue as MApp, app, options.historySource);
160
+ } else {
161
+ // 首次设置 root:仅当该页面 / 页面片尚无基线标记时,才写入「未修改的初始状态」基线。
162
+ // 配合「先恢复历史再 set root」:若基线已随历史恢复建立(恢复后已有基线),则此处不再
163
+ // 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
164
+ // 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
165
+ app.items?.forEach((pageNode) => {
166
+ if (pageNode?.id !== undefined && !historyService.getPageMarker(pageNode.id)) {
167
+ historyService.setPageMarker(pageNode.id, {
168
+ name: pageNode.name,
169
+ source: options.historySource,
170
+ });
171
+ }
172
+ });
173
+ }
152
174
  } else {
153
175
  this.state.pageLength = 0;
154
176
  this.state.pageFragmentLength = 0;
@@ -180,7 +202,30 @@ class Editor extends BaseService {
180
202
  root = toRaw(root);
181
203
  }
182
204
 
183
- return getNodeInfo(id, root);
205
+ if (!root) {
206
+ return { node: null, parent: null, page: null };
207
+ }
208
+
209
+ if (id === root.id) {
210
+ return { node: root, parent: null, page: null };
211
+ }
212
+
213
+ // 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
214
+ // 注意:不能直接使用 state.page,它可能与当前 root 不同步(指向已脱离的旧页面对象),
215
+ // 因此仅借用其 id,再从当前 root 中取回真正的页面对象(页面均为 root 的直接子节点,数量很少)。
216
+ const pageIdStr = `${this.get('page')?.id || ''}`;
217
+ const currentPageNode = root.items?.find((item) => `${item.id}` === pageIdStr);
218
+ if (currentPageNode && `${id}` !== pageIdStr) {
219
+ // util 仅读取 root.id 与 root.items,按容器结构传入当前页面是安全的
220
+ const info = getNodeInfo(id, currentPageNode);
221
+ if (info.node) {
222
+ return info;
223
+ }
224
+ }
225
+
226
+ // 回退:在完整 root 上查找;当前页面已搜索过,用 skip 跳过其子树避免重复遍历,
227
+ // 同时保留真实的 parent / page 引用(id 命中当前页面节点本身时会在跳过子树前先匹配到)
228
+ return getNodeInfo(id, root, currentPageNode);
184
229
  }
185
230
 
186
231
  /**
@@ -473,17 +518,17 @@ class Editor extends BaseService {
473
518
  if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
474
519
  const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
475
520
  if (!doNotPushHistory) {
521
+ const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
476
522
  this.pushOpHistory('add', {
477
- extra: {
478
- nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
479
- parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
480
- indexMap: Object.fromEntries(
481
- newNodes.map((n) => {
482
- const p = this.getParentById(n.id, false) as MContainer;
483
- return [n.id, p ? getNodeIndex(n.id, p) : -1];
484
- }),
485
- ),
486
- },
523
+ diff: newNodes.map((n) => {
524
+ const p = this.getParentById(n.id, false) as MContainer;
525
+ const idx = p ? getNodeIndex(n.id, p) : -1;
526
+ return {
527
+ newSchema: cloneDeep(toRaw(n)),
528
+ parentId,
529
+ index: typeof idx === 'number' ? idx : -1,
530
+ };
531
+ }),
487
532
  pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
488
533
  historyDescription,
489
534
  source: historySource,
@@ -596,7 +641,7 @@ class Editor extends BaseService {
596
641
 
597
642
  const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
598
643
 
599
- const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
644
+ const removedItems: StepDiffItem<MNode>[] = [];
600
645
  let pageForOp: { name: string; id: Id } | null = null;
601
646
  if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
602
647
  for (const n of nodes) {
@@ -607,7 +652,7 @@ class Editor extends BaseService {
607
652
  }
608
653
  const idx = getNodeIndex(curNode.id, parent);
609
654
  removedItems.push({
610
- node: cloneDeep(toRaw(curNode)),
655
+ oldSchema: cloneDeep(toRaw(curNode)),
611
656
  parentId: parent.id,
612
657
  index: typeof idx === 'number' ? idx : -1,
613
658
  });
@@ -620,7 +665,7 @@ class Editor extends BaseService {
620
665
  if (removedItems.length > 0 && pageForOp) {
621
666
  if (!doNotPushHistory) {
622
667
  this.pushOpHistory('remove', {
623
- extra: { removedItems },
668
+ diff: removedItems,
624
669
  pageData: pageForOp,
625
670
  historyDescription,
626
671
  source: historySource,
@@ -635,7 +680,7 @@ class Editor extends BaseService {
635
680
 
636
681
  public async doUpdate(
637
682
  config: MNode,
638
- { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
683
+ { changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
639
684
  ): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
640
685
  const root = this.get('root');
641
686
  if (!root) throw new Error('root为空');
@@ -655,7 +700,7 @@ class Editor extends BaseService {
655
700
  if (!newConfig.type) throw new Error('配置缺少type值');
656
701
 
657
702
  if (newConfig.type === NodeType.ROOT) {
658
- this.set('root', newConfig as MApp);
703
+ this.set('root', newConfig as MApp, { historySource });
659
704
  return {
660
705
  oldNode: node,
661
706
  newNode: newConfig,
@@ -736,7 +781,7 @@ class Editor extends BaseService {
736
781
  const updateData = await Promise.all(
737
782
  nodes.map((node, index) => {
738
783
  const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
739
- return this.doUpdate(node, { changeRecords: recordsForNode });
784
+ return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
740
785
  }),
741
786
  );
742
787
 
@@ -746,15 +791,15 @@ class Editor extends BaseService {
746
791
  if (!doNotPushHistory) {
747
792
  const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
748
793
  this.pushOpHistory('update', {
749
- extra: {
750
- updatedItems: updateData.map((d) => ({
794
+ // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
795
+ // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
796
+ diff: buildUpdateDiff(
797
+ updateData.map((d) => ({
751
798
  oldNode: cloneDeep(d.oldNode),
752
- newNode: cloneDeep(toRaw(d.newNode)),
753
- // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
754
- // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
799
+ newNode: cloneDeep(d.newNode),
755
800
  changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
756
801
  })),
757
- },
802
+ ),
758
803
  pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
759
804
  historyDescription,
760
805
  source: historySource,
@@ -996,9 +1041,7 @@ class Editor extends BaseService {
996
1041
  'update',
997
1042
 
998
1043
  {
999
- extra: {
1000
- updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
1001
- },
1044
+ diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
1002
1045
  pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1003
1046
  historyDescription,
1004
1047
  source: historySource,
@@ -1098,7 +1141,7 @@ class Editor extends BaseService {
1098
1141
  }));
1099
1142
  const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
1100
1143
  this.pushOpHistory('update', {
1101
- extra: { updatedItems },
1144
+ diff: buildUpdateDiff(updatedItems),
1102
1145
  pageData: historyPage,
1103
1146
  historyDescription,
1104
1147
  source: historySource,
@@ -1178,7 +1221,7 @@ class Editor extends BaseService {
1178
1221
  if (!doNotPushHistory) {
1179
1222
  const pageForOp = this.getNodeInfo(configs[0].id, false).page;
1180
1223
  this.pushOpHistory('update', {
1181
- extra: { updatedItems },
1224
+ diff: buildUpdateDiff(updatedItems),
1182
1225
  pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1183
1226
  historyDescription,
1184
1227
  source: historySource,
@@ -1190,6 +1233,86 @@ class Editor extends BaseService {
1190
1233
  this.emit('drag-to', { targetIndex, configs, targetParent });
1191
1234
  }
1192
1235
 
1236
+ // #region AndGetHistoryId
1237
+ /**
1238
+ * 下列 *AndGetHistoryId 方法与对应的普通操作(add / remove / update ...)行为完全一致,
1239
+ * 唯一区别是返回值为本次写入历史栈的历史记录 uuid({@link StepValue.uuid}),
1240
+ * 而非节点 / 节点数组。可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
1241
+ *
1242
+ * 当本次操作未写入历史(doNotPushHistory 为 true、或操作无实际变更 / 提前返回)时返回 null。
1243
+ */
1244
+
1245
+ /** 等价于 {@link add},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
1246
+ public async addAndGetHistoryId(
1247
+ addNode: AddMNode | MNode[],
1248
+ parent?: MContainer | null,
1249
+ options: DslOpOptions = {},
1250
+ ): Promise<string | null> {
1251
+ this.lastPushedHistoryId = null;
1252
+ await this.add(addNode, parent, options);
1253
+ return this.lastPushedHistoryId;
1254
+ }
1255
+
1256
+ /** 等价于 {@link remove},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
1257
+ public async removeAndGetHistoryId(
1258
+ nodeOrNodeList: MNode | MNode[],
1259
+ options: DslOpOptions = {},
1260
+ ): Promise<string | null> {
1261
+ this.lastPushedHistoryId = null;
1262
+ await this.remove(nodeOrNodeList, options);
1263
+ return this.lastPushedHistoryId;
1264
+ }
1265
+
1266
+ /** 等价于 {@link update},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
1267
+ public async updateAndGetHistoryId(
1268
+ config: MNode | MNode[],
1269
+ data: {
1270
+ changeRecords?: ChangeRecord[];
1271
+ changeRecordList?: ChangeRecord[][];
1272
+ doNotPushHistory?: boolean;
1273
+ historyDescription?: string;
1274
+ historySource?: HistoryOpSource;
1275
+ } = {},
1276
+ ): Promise<string | null> {
1277
+ this.lastPushedHistoryId = null;
1278
+ await this.update(config, data);
1279
+ return this.lastPushedHistoryId;
1280
+ }
1281
+
1282
+ /** 等价于 {@link moveLayer},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
1283
+ public async moveLayerAndGetHistoryId(
1284
+ offset: number | LayerOffset,
1285
+ options: DslOpOptions = {},
1286
+ ): Promise<string | null> {
1287
+ this.lastPushedHistoryId = null;
1288
+ await this.moveLayer(offset, options);
1289
+ return this.lastPushedHistoryId;
1290
+ }
1291
+
1292
+ /** 等价于 {@link moveToContainer},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
1293
+ public async moveToContainerAndGetHistoryId(
1294
+ config: MNode | MNode[],
1295
+ targetId: Id,
1296
+ options: DslOpOptions = {},
1297
+ ): Promise<string | null> {
1298
+ this.lastPushedHistoryId = null;
1299
+ await this.moveToContainer(config, targetId, options);
1300
+ return this.lastPushedHistoryId;
1301
+ }
1302
+
1303
+ /** 等价于 {@link dragTo},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
1304
+ public async dragToAndGetHistoryId(
1305
+ config: MNode | MNode[],
1306
+ targetParent: MContainer,
1307
+ targetIndex: number,
1308
+ options: DslOpOptions = {},
1309
+ ): Promise<string | null> {
1310
+ this.lastPushedHistoryId = null;
1311
+ await this.dragTo(config, targetParent, targetIndex, options);
1312
+ return this.lastPushedHistoryId;
1313
+ }
1314
+ // #endregion AndGetHistoryId
1315
+
1193
1316
  /**
1194
1317
  * 撤销当前操作
1195
1318
  * @returns 被撤销的操作
@@ -1236,12 +1359,14 @@ class Editor extends BaseService {
1236
1359
  if (!entry?.applied) return null;
1237
1360
 
1238
1361
  const { step } = entry;
1362
+ // 初始基线(index 0 的 initial step)是栈底线,不可回滚。
1363
+ if (step.opType === 'initial') return null;
1239
1364
  const root = this.get('root');
1240
1365
  if (!root) return null;
1241
1366
 
1242
1367
  // 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
1243
1368
  if (step.opType === 'update') {
1244
- const items = step.updatedItems ?? [];
1369
+ const items = step.diff ?? [];
1245
1370
  if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
1246
1371
  }
1247
1372
 
@@ -1260,9 +1385,9 @@ class Editor extends BaseService {
1260
1385
  switch (step.opType) {
1261
1386
  case 'add': {
1262
1387
  // 原本是新增 → revert 即删除当时被加入的节点
1263
- const nodes = step.nodes ?? [];
1264
- for (const n of nodes) {
1265
- const existing = this.getNodeById(n.id, false);
1388
+ for (const { newSchema } of step.diff ?? []) {
1389
+ if (!newSchema) continue;
1390
+ const existing = this.getNodeById(newSchema.id, false);
1266
1391
  if (existing) {
1267
1392
  await this.remove(existing, opts);
1268
1393
  }
@@ -1272,35 +1397,40 @@ class Editor extends BaseService {
1272
1397
  case 'remove': {
1273
1398
  // 原本是删除 → revert 即把节点按原父容器加回来。
1274
1399
  // 按原 index 升序逐个插回,先小后大避免索引漂移。
1275
- const items = step.removedItems ?? [];
1276
- const sorted = [...items].sort((a, b) => a.index - b.index);
1277
- for (const { node, parentId } of sorted) {
1400
+ const items = step.diff ?? [];
1401
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1402
+ for (const { oldSchema, parentId } of sorted) {
1403
+ if (!oldSchema || parentId === undefined) continue;
1278
1404
  const parent = this.getNodeById(parentId, false) as MContainer | null;
1279
1405
  if (parent) {
1280
- await this.add([cloneDeep(node)] as MNode[], parent, opts);
1406
+ await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
1281
1407
  }
1282
1408
  }
1283
1409
  break;
1284
1410
  }
1285
1411
  case 'update': {
1286
- // 原本是更新 → revert 即把 oldNode 的值写回;
1412
+ // 原本是更新 → revert 即把 oldSchema 的值写回;
1287
1413
  // 优先按 changeRecords 局部 patch(仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
1288
- const items = step.updatedItems ?? [];
1289
- const configs = items.map(({ oldNode, newNode, changeRecords }) => {
1290
- if (changeRecords?.length) {
1291
- const patch: MNode = { id: newNode.id, type: newNode.type };
1292
- for (const record of changeRecords) {
1293
- if (!record.propPath) {
1294
- // 没有 propPath 视为整节点替换
1295
- return cloneDeep(oldNode);
1414
+ const items = step.diff ?? [];
1415
+ const configs = items
1416
+ .filter((item) => item.oldSchema && item.newSchema)
1417
+ .map(({ oldSchema, newSchema, changeRecords }) => {
1418
+ const oldNode = oldSchema!;
1419
+ const newNode = newSchema!;
1420
+ if (changeRecords?.length) {
1421
+ const patch: MNode = { id: newNode.id, type: newNode.type };
1422
+ for (const record of changeRecords) {
1423
+ if (!record.propPath) {
1424
+ // 没有 propPath 视为整节点替换
1425
+ return cloneDeep(oldNode);
1426
+ }
1427
+ const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
1428
+ setValueByKeyPath(record.propPath, value, patch);
1296
1429
  }
1297
- const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
1298
- setValueByKeyPath(record.propPath, value, patch);
1430
+ return patch;
1299
1431
  }
1300
- return patch;
1301
- }
1302
- return cloneDeep(oldNode);
1303
- });
1432
+ return cloneDeep(oldNode);
1433
+ });
1304
1434
  if (configs.length) {
1305
1435
  await this.update(configs, { historyDescription, historySource: 'rollback' });
1306
1436
  }
@@ -1320,6 +1450,20 @@ class Editor extends BaseService {
1320
1450
  return revertedStep;
1321
1451
  }
1322
1452
 
1453
+ /**
1454
+ * 通过历史记录 uuid 回滚当前页面的某条历史步骤,语义与 {@link revertPageStep} 完全一致,
1455
+ * 仅入参从 index 改为 uuid({@link StepValue.uuid})。uuid 不随栈内步骤增删而变化,
1456
+ * 更适合业务侧持有引用后再回滚(埋点、跨端同步等场景)。
1457
+ *
1458
+ * @param uuid 目标历史记录的 uuid,通常由 *AndGetHistoryId 方法返回
1459
+ * @returns 反向后产生的新 step;找不到对应 uuid / 未应用 / 反向失败时返回 null
1460
+ */
1461
+ public async revertPageStepById(uuid: string): Promise<StepValue | null> {
1462
+ const index = historyService.getPageStepIndexByUuid(uuid);
1463
+ if (index < 0) return null;
1464
+ return this.revertPageStep(index);
1465
+ }
1466
+
1323
1467
  /**
1324
1468
  * 跳转当前页面历史栈到指定游标位置。
1325
1469
  *
@@ -1416,34 +1560,119 @@ class Editor extends BaseService {
1416
1560
  this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
1417
1561
  }
1418
1562
 
1563
+ /**
1564
+ * 比较「上一次 root」与「新 root」的页面 / 页面片,按页面粒度把整体替换拆成历史记录:
1565
+ * - 新旧都存在且内容变化的页面 → 一条 `update`(整页快照替换,无 changeRecords);
1566
+ * - 仅新 root 存在的页面 → 一条 `add`;
1567
+ * - 仅旧 root 存在的页面 → 一条 `remove`。
1568
+ *
1569
+ * 每条记录落到对应页面自己的历史栈(与普通节点操作一致),并标记来源 `source`。
1570
+ * 内容未变化的页面不产生记录,避免重复设置相同 DSL 时产生噪声。
1571
+ */
1572
+ private pushRootDiffHistory(preRoot: MApp, nextRoot: MApp, source?: HistoryOpSource): void {
1573
+ const prevPages = preRoot?.items || [];
1574
+ const nextPages = nextRoot?.items || [];
1575
+ const prevMap = new Map(prevPages.map((p) => [`${p.id}`, p]));
1576
+ const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
1577
+ const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
1578
+
1579
+ nextPages.forEach((nextPage) => {
1580
+ const prevPage = prevMap.get(`${nextPage.id}`);
1581
+ if (!prevPage) {
1582
+ this.pushPageDiffStep(
1583
+ 'add',
1584
+ nextPage,
1585
+ { newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
1586
+ source,
1587
+ );
1588
+ } else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
1589
+ this.pushPageDiffStep(
1590
+ 'update',
1591
+ nextPage,
1592
+ { oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
1593
+ source,
1594
+ );
1595
+ }
1596
+ });
1597
+
1598
+ prevPages.forEach((prevPage) => {
1599
+ if (!nextMap.has(`${prevPage.id}`)) {
1600
+ this.pushPageDiffStep(
1601
+ 'remove',
1602
+ prevPage,
1603
+ { oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
1604
+ source,
1605
+ );
1606
+ }
1607
+ });
1608
+ }
1609
+
1610
+ /**
1611
+ * 构造一条页面级「set root」历史记录(不携带选区 / modifiedNodeIds 上下文)并落到该页面自己的栈。
1612
+ *
1613
+ * 连续 set root 替换:若该页栈最新一条已是**同来源**的 set root 记录({@link StepValue.rootStep} 且 `source` 相同),
1614
+ * 则用本次记录**替换**它而非新增,避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录;
1615
+ * 来源不同则照常新增(initial 基线不是 rootStep,不在此列)。
1616
+ */
1617
+ private pushPageDiffStep(
1618
+ opType: HistoryOpType,
1619
+ page: MPage | MPageFragment,
1620
+ diffItem: StepDiffItem<MNode>,
1621
+ source?: HistoryOpSource,
1622
+ ): void {
1623
+ const step: StepValue = {
1624
+ uuid: guid(),
1625
+ data: { name: page.name || '', id: page.id },
1626
+ opType,
1627
+ selectedBefore: [],
1628
+ selectedAfter: [],
1629
+ modifiedNodeIds: new Map(),
1630
+ diff: [diffItem],
1631
+ rootStep: true,
1632
+ };
1633
+ if (source) step.source = source;
1634
+
1635
+ const top = historyService.getCurrentPageStep(page.id);
1636
+ if (top?.rootStep && top.source === source) {
1637
+ historyService.replaceCurrentPageStep(step, page.id);
1638
+ } else {
1639
+ historyService.push(step, page.id);
1640
+ }
1641
+ }
1642
+
1419
1643
  private pushOpHistory(
1420
1644
  opType: HistoryOpType,
1421
1645
  {
1422
- extra,
1646
+ diff,
1423
1647
  pageData,
1424
1648
  historyDescription,
1425
1649
  source,
1426
1650
  }: {
1427
- extra: Partial<StepValue>;
1651
+ diff: StepDiffItem<MNode>[];
1428
1652
  pageData: { name: string; id: Id };
1429
1653
  historyDescription?: string;
1430
1654
  source?: HistoryOpSource;
1431
1655
  },
1432
- ) {
1656
+ ): string | null {
1433
1657
  const step: StepValue = {
1658
+ uuid: guid(),
1434
1659
  data: pageData,
1435
1660
  opType,
1436
1661
  selectedBefore: this.selectionBeforeOp ?? [],
1437
1662
  selectedAfter: this.get('nodes').map((n) => n.id),
1438
1663
  modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
1439
- ...extra,
1664
+ diff,
1440
1665
  };
1441
1666
  if (historyDescription) step.historyDescription = historyDescription;
1442
1667
  if (source) step.source = source;
1443
1668
  // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
1444
1669
  // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
1445
- historyService.push(step, pageData.id);
1670
+ const pushed = historyService.push(step, pageData.id);
1671
+ // push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
1672
+ const historyId = pushed ? step.uuid : null;
1673
+ this.lastPushedHistoryId = historyId;
1446
1674
  this.selectionBeforeOp = null;
1675
+ return historyId;
1447
1676
  }
1448
1677
 
1449
1678
  /**
@@ -1459,6 +1688,8 @@ class Editor extends BaseService {
1459
1688
  * @param reverse true = 撤销,false = 重做
1460
1689
  */
1461
1690
  private async applyHistoryOp(step: StepValue, reverse: boolean) {
1691
+ // 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
1692
+ if (step.opType === 'initial') return;
1462
1693
  const root = this.get('root');
1463
1694
  const stage = this.get('stage');
1464
1695
  if (!root) return;
@@ -1467,52 +1698,52 @@ class Editor extends BaseService {
1467
1698
 
1468
1699
  switch (step.opType) {
1469
1700
  case 'add': {
1470
- const nodes = step.nodes ?? [];
1701
+ const items = step.diff ?? [];
1471
1702
  if (reverse) {
1472
1703
  // 撤销 add:把当时加入的节点删除
1473
- for (const n of nodes) {
1474
- const existing = this.getNodeById(n.id, false);
1704
+ for (const { newSchema } of items) {
1705
+ if (!newSchema) continue;
1706
+ const existing = this.getNodeById(newSchema.id, false);
1475
1707
  if (existing) {
1476
1708
  await this.remove(existing, commonOpts);
1477
1709
  }
1478
1710
  }
1479
1711
  } else {
1480
- // 重做 add:按记录的 indexMap 把节点重新插回父容器
1481
- const parent = this.getNodeById(step.parentId!, false) as MContainer | null;
1482
- if (parent) {
1483
- // 按目标 index 升序逐个插入,先小后大避免索引漂移
1484
- const sorted = [...nodes].sort((a, b) => (step.indexMap?.[a.id] ?? 0) - (step.indexMap?.[b.id] ?? 0));
1485
- for (const n of sorted) {
1486
- const idx = step.indexMap?.[n.id];
1487
- if (parent.items) {
1488
- if (typeof idx === 'number' && idx >= 0 && idx < parent.items.length) {
1489
- parent.items.splice(idx, 0, cloneDeep(n));
1490
- } else {
1491
- parent.items.push(cloneDeep(n));
1492
- }
1493
- await stage?.add({
1494
- config: cloneDeep(n),
1495
- parent: cloneDeep(parent),
1496
- parentId: parent.id,
1497
- root: cloneDeep(root),
1498
- });
1712
+ // 重做 add:按记录的 parentId / index 把节点重新插回父容器。
1713
+ // 按目标 index 升序逐个插入,先小后大避免索引漂移
1714
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1715
+ for (const { newSchema, parentId, index } of sorted) {
1716
+ if (!newSchema || parentId === undefined) continue;
1717
+ const parent = this.getNodeById(parentId, false) as MContainer | null;
1718
+ if (parent?.items) {
1719
+ if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
1720
+ parent.items.splice(index, 0, cloneDeep(newSchema));
1721
+ } else {
1722
+ parent.items.push(cloneDeep(newSchema));
1499
1723
  }
1724
+ await stage?.add({
1725
+ config: cloneDeep(newSchema),
1726
+ parent: cloneDeep(parent),
1727
+ parentId: parent.id,
1728
+ root: cloneDeep(root),
1729
+ });
1500
1730
  }
1501
1731
  }
1502
1732
  }
1503
1733
  break;
1504
1734
  }
1505
1735
  case 'remove': {
1506
- const items = step.removedItems ?? [];
1736
+ const items = step.diff ?? [];
1507
1737
  if (reverse) {
1508
1738
  // 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
1509
- const sorted = [...items].sort((a, b) => a.index - b.index);
1510
- for (const { node, parentId, index } of sorted) {
1739
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1740
+ for (const { oldSchema, parentId, index } of sorted) {
1741
+ if (!oldSchema || parentId === undefined) continue;
1511
1742
  const parent = this.getNodeById(parentId, false) as MContainer | null;
1512
1743
  if (parent?.items) {
1513
- parent.items.splice(index, 0, cloneDeep(node));
1744
+ parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
1514
1745
  await stage?.add({
1515
- config: cloneDeep(node),
1746
+ config: cloneDeep(oldSchema),
1516
1747
  parent: cloneDeep(parent),
1517
1748
  parentId,
1518
1749
  root: cloneDeep(root),
@@ -1521,8 +1752,9 @@ class Editor extends BaseService {
1521
1752
  }
1522
1753
  } else {
1523
1754
  // 重做 remove:再删一次
1524
- for (const { node } of items) {
1525
- const existing = this.getNodeById(node.id, false);
1755
+ for (const { oldSchema } of items) {
1756
+ if (!oldSchema) continue;
1757
+ const existing = this.getNodeById(oldSchema.id, false);
1526
1758
  if (existing) {
1527
1759
  await this.remove(existing, commonOpts);
1528
1760
  }
@@ -1531,27 +1763,31 @@ class Editor extends BaseService {
1531
1763
  break;
1532
1764
  }
1533
1765
  case 'update': {
1534
- const items = step.updatedItems ?? [];
1766
+ const items = step.diff ?? [];
1535
1767
  // 优先按 changeRecords 局部 patch:仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
1536
1768
  // 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
1537
- const configs = items.map(({ oldNode, newNode, changeRecords }) => {
1538
- if (changeRecords?.length) {
1539
- const sourceForValues = reverse ? oldNode : newNode;
1540
- // 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
1541
- // 后续 update -> mergeWith 会与现有节点深合并,patch 中未涉及的字段不会被改动。
1542
- const patch: MNode = { id: newNode.id, type: newNode.type };
1543
- for (const record of changeRecords) {
1544
- if (!record.propPath) {
1545
- // 没有 propPath 视为整节点替换
1546
- return cloneDeep(sourceForValues);
1769
+ const configs = items
1770
+ .filter((item) => item.oldSchema && item.newSchema)
1771
+ .map(({ oldSchema, newSchema, changeRecords }) => {
1772
+ const oldNode = oldSchema!;
1773
+ const newNode = newSchema!;
1774
+ if (changeRecords?.length) {
1775
+ const sourceForValues = reverse ? oldNode : newNode;
1776
+ // 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
1777
+ // 后续 update -> mergeWith 会与现有节点深合并,patch 中未涉及的字段不会被改动。
1778
+ const patch: MNode = { id: newNode.id, type: newNode.type };
1779
+ for (const record of changeRecords) {
1780
+ if (!record.propPath) {
1781
+ // 没有 propPath 视为整节点替换
1782
+ return cloneDeep(sourceForValues);
1783
+ }
1784
+ const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
1785
+ setValueByKeyPath(record.propPath, value, patch);
1547
1786
  }
1548
- const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
1549
- setValueByKeyPath(record.propPath, value, patch);
1787
+ return patch;
1550
1788
  }
1551
- return patch;
1552
- }
1553
- return cloneDeep(reverse ? oldNode : newNode);
1554
- });
1789
+ return cloneDeep(reverse ? oldNode : newNode);
1790
+ });
1555
1791
  if (configs.length) {
1556
1792
  await this.update(configs, { doNotPushHistory: true });
1557
1793
  }