@tmagic/editor 1.8.0-beta.4 → 1.8.0-beta.6

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