@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,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';
@@ -36,8 +44,10 @@ import type {
36
44
  DslOpOptions,
37
45
  EditorEvents,
38
46
  EditorNodeInfo,
47
+ HistoryOpSource,
39
48
  HistoryOpType,
40
49
  PastePosition,
50
+ StepDiffItem,
41
51
  StepValue,
42
52
  StoreState,
43
53
  StoreStateKey,
@@ -50,6 +60,7 @@ import {
50
60
  classifyDragSources,
51
61
  collectRelatedNodes,
52
62
  COPY_STORAGE_KEY,
63
+ describeStepForRevert,
53
64
  editorNodeMergeCustomizer,
54
65
  fixNodePosition,
55
66
  getInitPositionStyle,
@@ -67,36 +78,18 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
67
78
  type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
68
79
 
69
80
  /**
70
- * 给「回滚」生成的新 step 用的简短描述生成器。
71
- * UI `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。
81
+ * 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
82
+ * `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
83
+ * 缺省(未传 / 空数组)才退化为整节点替换。
72
84
  */
73
- const describeStepForRevert = (step: StepValue): string => {
74
- switch (step.opType) {
75
- case 'add': {
76
- const count = step.nodes?.length ?? 0;
77
- const node = step.nodes?.[0];
78
- const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
79
- return `撤回新增 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
80
- }
81
- case 'remove': {
82
- const count = step.removedItems?.length ?? 0;
83
- const node = step.removedItems?.[0]?.node;
84
- const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
85
- return `还原已删除的 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
86
- }
87
- case 'update':
88
- default: {
89
- const items = step.updatedItems ?? [];
90
- if (items.length === 1) {
91
- const { newNode, oldNode, changeRecords } = items[0];
92
- const target = newNode?.name || newNode?.type || oldNode?.name || oldNode?.type || `${newNode?.id ?? ''}`;
93
- const propPath = changeRecords?.[0]?.propPath;
94
- return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
95
- }
96
- return `还原 ${items.length} 个节点的修改`;
97
- }
98
- }
99
- };
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
+ }));
100
93
 
101
94
  class Editor extends BaseService {
102
95
  public state: StoreState = reactive({
@@ -115,6 +108,12 @@ class Editor extends BaseService {
115
108
  alwaysMultiSelect: false,
116
109
  });
117
110
  private selectionBeforeOp: Id[] | null = null;
111
+ /**
112
+ * 最近一次 pushOpHistory 写入的历史记录 uuid。
113
+ * 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id;
114
+ * 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
115
+ */
116
+ private lastPushedHistoryId: string | null = null;
118
117
 
119
118
  constructor() {
120
119
  super(
@@ -128,8 +127,13 @@ class Editor extends BaseService {
128
127
  * 设置当前指点节点配置
129
128
  * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
130
129
  * @param value MNode
130
+ * @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效)
131
131
  */
132
- 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
+ ) {
133
137
  const preValue = this.state[name];
134
138
  this.state[name] = value;
135
139
 
@@ -148,6 +152,25 @@ class Editor extends BaseService {
148
152
  this.state.pageLength = getPageList(app).length || 0;
149
153
  this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
150
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
+ }
151
174
  } else {
152
175
  this.state.pageLength = 0;
153
176
  this.state.pageFragmentLength = 0;
@@ -179,7 +202,30 @@ class Editor extends BaseService {
179
202
  root = toRaw(root);
180
203
  }
181
204
 
182
- 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);
183
229
  }
184
230
 
185
231
  /**
@@ -406,7 +452,13 @@ class Editor extends BaseService {
406
452
  public async add(
407
453
  addNode: AddMNode | MNode[],
408
454
  parent?: MContainer | null,
409
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
455
+ {
456
+ doNotSelect = false,
457
+ doNotSwitchPage = false,
458
+ doNotPushHistory = false,
459
+ historyDescription,
460
+ historySource,
461
+ }: DslOpOptions = {},
410
462
  ): Promise<MNode | MNode[]> {
411
463
  this.captureSelectionBeforeOp();
412
464
 
@@ -466,21 +518,21 @@ class Editor extends BaseService {
466
518
  if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
467
519
  const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
468
520
  if (!doNotPushHistory) {
469
- this.pushOpHistory(
470
- 'add',
471
- {
472
- nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
473
- parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
474
- indexMap: Object.fromEntries(
475
- newNodes.map((n) => {
476
- const p = this.getParentById(n.id, false) as MContainer;
477
- return [n.id, p ? getNodeIndex(n.id, p) : -1];
478
- }),
479
- ),
480
- },
481
- { name: pageForOp?.name || '', id: pageForOp!.id },
521
+ const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
522
+ this.pushOpHistory('add', {
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
+ }),
532
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
482
533
  historyDescription,
483
- );
534
+ source: historySource,
535
+ });
484
536
  } else {
485
537
  this.selectionBeforeOp = null;
486
538
  }
@@ -577,13 +629,19 @@ class Editor extends BaseService {
577
629
  */
578
630
  public async remove(
579
631
  nodeOrNodeList: MNode | MNode[],
580
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
632
+ {
633
+ doNotSelect = false,
634
+ doNotSwitchPage = false,
635
+ doNotPushHistory = false,
636
+ historyDescription,
637
+ historySource,
638
+ }: DslOpOptions = {},
581
639
  ): Promise<void> {
582
640
  this.captureSelectionBeforeOp();
583
641
 
584
642
  const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
585
643
 
586
- const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
644
+ const removedItems: StepDiffItem<MNode>[] = [];
587
645
  let pageForOp: { name: string; id: Id } | null = null;
588
646
  if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
589
647
  for (const n of nodes) {
@@ -594,7 +652,7 @@ class Editor extends BaseService {
594
652
  }
595
653
  const idx = getNodeIndex(curNode.id, parent);
596
654
  removedItems.push({
597
- node: cloneDeep(toRaw(curNode)),
655
+ oldSchema: cloneDeep(toRaw(curNode)),
598
656
  parentId: parent.id,
599
657
  index: typeof idx === 'number' ? idx : -1,
600
658
  });
@@ -606,7 +664,12 @@ class Editor extends BaseService {
606
664
 
607
665
  if (removedItems.length > 0 && pageForOp) {
608
666
  if (!doNotPushHistory) {
609
- this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription);
667
+ this.pushOpHistory('remove', {
668
+ diff: removedItems,
669
+ pageData: pageForOp,
670
+ historyDescription,
671
+ source: historySource,
672
+ });
610
673
  } else {
611
674
  this.selectionBeforeOp = null;
612
675
  }
@@ -617,7 +680,7 @@ class Editor extends BaseService {
617
680
 
618
681
  public async doUpdate(
619
682
  config: MNode,
620
- { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
683
+ { changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
621
684
  ): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
622
685
  const root = this.get('root');
623
686
  if (!root) throw new Error('root为空');
@@ -637,7 +700,7 @@ class Editor extends BaseService {
637
700
  if (!newConfig.type) throw new Error('配置缺少type值');
638
701
 
639
702
  if (newConfig.type === NodeType.ROOT) {
640
- this.set('root', newConfig as MApp);
703
+ this.set('root', newConfig as MApp, { historySource });
641
704
  return {
642
705
  oldNode: node,
643
706
  newNode: newConfig,
@@ -704,11 +767,12 @@ class Editor extends BaseService {
704
767
  changeRecordList?: ChangeRecord[][];
705
768
  doNotPushHistory?: boolean;
706
769
  historyDescription?: string;
770
+ historySource?: HistoryOpSource;
707
771
  } = {},
708
772
  ): Promise<MNode | MNode[]> {
709
773
  this.captureSelectionBeforeOp();
710
774
 
711
- const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription } = data;
775
+ const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription, historySource } = data;
712
776
 
713
777
  const nodes = Array.isArray(config) ? config : [config];
714
778
 
@@ -717,7 +781,7 @@ class Editor extends BaseService {
717
781
  const updateData = await Promise.all(
718
782
  nodes.map((node, index) => {
719
783
  const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
720
- return this.doUpdate(node, { changeRecords: recordsForNode });
784
+ return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
721
785
  }),
722
786
  );
723
787
 
@@ -726,20 +790,20 @@ class Editor extends BaseService {
726
790
  if (curNodes.length) {
727
791
  if (!doNotPushHistory) {
728
792
  const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
729
- this.pushOpHistory(
730
- 'update',
731
- {
732
- updatedItems: updateData.map((d) => ({
793
+ this.pushOpHistory('update', {
794
+ // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
795
+ // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
796
+ diff: buildUpdateDiff(
797
+ updateData.map((d) => ({
733
798
  oldNode: cloneDeep(d.oldNode),
734
- newNode: cloneDeep(toRaw(d.newNode)),
735
- // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
736
- // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
799
+ newNode: cloneDeep(d.newNode),
737
800
  changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
738
801
  })),
739
- },
740
- { name: pageForOp?.name || '', id: pageForOp!.id },
802
+ ),
803
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
741
804
  historyDescription,
742
- );
805
+ source: historySource,
806
+ });
743
807
  } else {
744
808
  this.selectionBeforeOp = null;
745
809
  }
@@ -763,7 +827,7 @@ class Editor extends BaseService {
763
827
  public async sort(
764
828
  id1: Id,
765
829
  id2: Id,
766
- { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
830
+ { doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {},
767
831
  ): Promise<void> {
768
832
  this.captureSelectionBeforeOp();
769
833
 
@@ -783,7 +847,7 @@ class Editor extends BaseService {
783
847
 
784
848
  parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
785
849
 
786
- await this.update(parent, { doNotPushHistory });
850
+ await this.update(parent, { doNotPushHistory, historySource });
787
851
  if (!doNotSelect) {
788
852
  await this.select(node);
789
853
  }
@@ -836,7 +900,13 @@ class Editor extends BaseService {
836
900
  public async paste(
837
901
  position: PastePosition = {},
838
902
  collectorOptions?: TargetOptions,
839
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
903
+ {
904
+ doNotSelect = false,
905
+ doNotSwitchPage = false,
906
+ doNotPushHistory = false,
907
+ historyDescription,
908
+ historySource,
909
+ }: DslOpOptions = {},
840
910
  ): Promise<MNode | MNode[] | void> {
841
911
  const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
842
912
  if (!Array.isArray(config)) return;
@@ -857,7 +927,13 @@ class Editor extends BaseService {
857
927
  propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
858
928
  }
859
929
 
860
- return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage, doNotPushHistory });
930
+ return this.add(pasteConfigs, parent, {
931
+ doNotSelect,
932
+ doNotSwitchPage,
933
+ doNotPushHistory,
934
+ historyDescription,
935
+ historySource,
936
+ });
861
937
  }
862
938
 
863
939
  public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@@ -893,14 +969,14 @@ class Editor extends BaseService {
893
969
  */
894
970
  public async alignCenter(
895
971
  config: MNode | MNode[],
896
- { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
972
+ { doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
897
973
  ): Promise<MNode | MNode[]> {
898
974
  const nodes = Array.isArray(config) ? config : [config];
899
975
  const stage = this.get('stage');
900
976
 
901
977
  const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
902
978
 
903
- const newNode = await this.update(newNodes, { doNotPushHistory });
979
+ const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource });
904
980
 
905
981
  if (!doNotSelect) {
906
982
  if (newNodes.length > 1) {
@@ -919,7 +995,10 @@ class Editor extends BaseService {
919
995
  * @param options 可选配置
920
996
  * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
921
997
  */
922
- public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise<void> {
998
+ public async moveLayer(
999
+ offset: number | LayerOffset,
1000
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1001
+ ): Promise<void> {
923
1002
  this.captureSelectionBeforeOp();
924
1003
 
925
1004
  const root = this.get('root');
@@ -960,10 +1039,13 @@ class Editor extends BaseService {
960
1039
  const pageForOp = this.getNodeInfo(node.id, false).page;
961
1040
  this.pushOpHistory(
962
1041
  'update',
1042
+
963
1043
  {
964
- updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
1044
+ diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
1045
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1046
+ historyDescription,
1047
+ source: historySource,
965
1048
  },
966
- { name: pageForOp?.name || '', id: pageForOp!.id },
967
1049
  );
968
1050
  } else {
969
1051
  this.selectionBeforeOp = null;
@@ -989,7 +1071,13 @@ class Editor extends BaseService {
989
1071
  public async moveToContainer(
990
1072
  config: MNode | MNode[],
991
1073
  targetId: Id,
992
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
1074
+ {
1075
+ doNotSelect = false,
1076
+ doNotSwitchPage = false,
1077
+ doNotPushHistory = false,
1078
+ historyDescription,
1079
+ historySource,
1080
+ }: DslOpOptions = {},
993
1081
  ): Promise<MNode | MNode[]> {
994
1082
  const isBatch = Array.isArray(config);
995
1083
  const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
@@ -1052,7 +1140,12 @@ class Editor extends BaseService {
1052
1140
  newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
1053
1141
  }));
1054
1142
  const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
1055
- this.pushOpHistory('update', { updatedItems }, historyPage);
1143
+ this.pushOpHistory('update', {
1144
+ diff: buildUpdateDiff(updatedItems),
1145
+ pageData: historyPage,
1146
+ historyDescription,
1147
+ source: historySource,
1148
+ });
1056
1149
  } else {
1057
1150
  this.selectionBeforeOp = null;
1058
1151
  }
@@ -1064,7 +1157,7 @@ class Editor extends BaseService {
1064
1157
  config: MNode | MNode[],
1065
1158
  targetParent: MContainer,
1066
1159
  targetIndex: number,
1067
- { doNotPushHistory = false }: DslOpOptions = {},
1160
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1068
1161
  ) {
1069
1162
  this.captureSelectionBeforeOp();
1070
1163
 
@@ -1127,7 +1220,12 @@ class Editor extends BaseService {
1127
1220
  }
1128
1221
  if (!doNotPushHistory) {
1129
1222
  const pageForOp = this.getNodeInfo(configs[0].id, false).page;
1130
- this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
1223
+ this.pushOpHistory('update', {
1224
+ diff: buildUpdateDiff(updatedItems),
1225
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1226
+ historyDescription,
1227
+ source: historySource,
1228
+ });
1131
1229
  } else {
1132
1230
  this.selectionBeforeOp = null;
1133
1231
  }
@@ -1135,6 +1233,86 @@ class Editor extends BaseService {
1135
1233
  this.emit('drag-to', { targetIndex, configs, targetParent });
1136
1234
  }
1137
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
+
1138
1316
  /**
1139
1317
  * 撤销当前操作
1140
1318
  * @returns 被撤销的操作
@@ -1181,9 +1359,17 @@ class Editor extends BaseService {
1181
1359
  if (!entry?.applied) return null;
1182
1360
 
1183
1361
  const { step } = entry;
1362
+ // 初始基线(index 0 的 initial step)是栈底线,不可回滚。
1363
+ if (step.opType === 'initial') return null;
1184
1364
  const root = this.get('root');
1185
1365
  if (!root) return null;
1186
1366
 
1367
+ // 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
1368
+ if (step.opType === 'update') {
1369
+ const items = step.diff ?? [];
1370
+ if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
1371
+ }
1372
+
1187
1373
  // 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
1188
1374
  let revertedStep: StepValue | null = null;
1189
1375
  const captureRevert = (s: StepValue) => {
@@ -1193,15 +1379,15 @@ class Editor extends BaseService {
1193
1379
 
1194
1380
  const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
1195
1381
  // revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
1196
- const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription } as const;
1382
+ const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
1197
1383
 
1198
1384
  try {
1199
1385
  switch (step.opType) {
1200
1386
  case 'add': {
1201
1387
  // 原本是新增 → revert 即删除当时被加入的节点
1202
- const nodes = step.nodes ?? [];
1203
- for (const n of nodes) {
1204
- 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);
1205
1391
  if (existing) {
1206
1392
  await this.remove(existing, opts);
1207
1393
  }
@@ -1211,37 +1397,42 @@ class Editor extends BaseService {
1211
1397
  case 'remove': {
1212
1398
  // 原本是删除 → revert 即把节点按原父容器加回来。
1213
1399
  // 按原 index 升序逐个插回,先小后大避免索引漂移。
1214
- const items = step.removedItems ?? [];
1215
- const sorted = [...items].sort((a, b) => a.index - b.index);
1216
- 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;
1217
1404
  const parent = this.getNodeById(parentId, false) as MContainer | null;
1218
1405
  if (parent) {
1219
- await this.add([cloneDeep(node)] as MNode[], parent, opts);
1406
+ await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
1220
1407
  }
1221
1408
  }
1222
1409
  break;
1223
1410
  }
1224
1411
  case 'update': {
1225
- // 原本是更新 → revert 即把 oldNode 的值写回;
1412
+ // 原本是更新 → revert 即把 oldSchema 的值写回;
1226
1413
  // 优先按 changeRecords 局部 patch(仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
1227
- const items = step.updatedItems ?? [];
1228
- const configs = items.map(({ oldNode, newNode, changeRecords }) => {
1229
- if (changeRecords?.length) {
1230
- const patch: MNode = { id: newNode.id, type: newNode.type };
1231
- for (const record of changeRecords) {
1232
- if (!record.propPath) {
1233
- // 没有 propPath 视为整节点替换
1234
- 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);
1235
1429
  }
1236
- const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
1237
- setValueByKeyPath(record.propPath, value, patch);
1430
+ return patch;
1238
1431
  }
1239
- return patch;
1240
- }
1241
- return cloneDeep(oldNode);
1242
- });
1432
+ return cloneDeep(oldNode);
1433
+ });
1243
1434
  if (configs.length) {
1244
- await this.update(configs, { historyDescription });
1435
+ await this.update(configs, { historyDescription, historySource: 'rollback' });
1245
1436
  }
1246
1437
  break;
1247
1438
  }
@@ -1259,6 +1450,20 @@ class Editor extends BaseService {
1259
1450
  return revertedStep;
1260
1451
  }
1261
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
+
1262
1467
  /**
1263
1468
  * 跳转当前页面历史栈到指定游标位置。
1264
1469
  *
@@ -1285,14 +1490,21 @@ class Editor extends BaseService {
1285
1490
  return cursor;
1286
1491
  }
1287
1492
 
1288
- public async move(left: number, top: number, { doNotPushHistory = false }: DslOpOptions = {}) {
1493
+ public async move(
1494
+ left: number,
1495
+ top: number,
1496
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1497
+ ) {
1289
1498
  const node = toRaw(this.get('node'));
1290
1499
  if (!node || isPage(node)) return;
1291
1500
 
1292
1501
  const newStyle = calcMoveStyle(node.style || {}, left, top);
1293
1502
  if (!newStyle) return;
1294
1503
 
1295
- await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory });
1504
+ await this.update(
1505
+ { id: node.id, type: node.type, style: newStyle },
1506
+ { doNotPushHistory, historyDescription, historySource },
1507
+ );
1296
1508
  }
1297
1509
 
1298
1510
  public resetState() {
@@ -1348,25 +1560,119 @@ class Editor extends BaseService {
1348
1560
  this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
1349
1561
  }
1350
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
+
1351
1643
  private pushOpHistory(
1352
1644
  opType: HistoryOpType,
1353
- extra: Partial<StepValue>,
1354
- pageData: { name: string; id: Id },
1355
- historyDescription?: string,
1356
- ) {
1645
+ {
1646
+ diff,
1647
+ pageData,
1648
+ historyDescription,
1649
+ source,
1650
+ }: {
1651
+ diff: StepDiffItem<MNode>[];
1652
+ pageData: { name: string; id: Id };
1653
+ historyDescription?: string;
1654
+ source?: HistoryOpSource;
1655
+ },
1656
+ ): string | null {
1357
1657
  const step: StepValue = {
1658
+ uuid: guid(),
1358
1659
  data: pageData,
1359
1660
  opType,
1360
1661
  selectedBefore: this.selectionBeforeOp ?? [],
1361
1662
  selectedAfter: this.get('nodes').map((n) => n.id),
1362
1663
  modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
1363
- ...extra,
1664
+ diff,
1364
1665
  };
1365
1666
  if (historyDescription) step.historyDescription = historyDescription;
1667
+ if (source) step.source = source;
1366
1668
  // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
1367
1669
  // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
1368
- 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;
1369
1674
  this.selectionBeforeOp = null;
1675
+ return historyId;
1370
1676
  }
1371
1677
 
1372
1678
  /**
@@ -1382,6 +1688,8 @@ class Editor extends BaseService {
1382
1688
  * @param reverse true = 撤销,false = 重做
1383
1689
  */
1384
1690
  private async applyHistoryOp(step: StepValue, reverse: boolean) {
1691
+ // 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
1692
+ if (step.opType === 'initial') return;
1385
1693
  const root = this.get('root');
1386
1694
  const stage = this.get('stage');
1387
1695
  if (!root) return;
@@ -1390,52 +1698,52 @@ class Editor extends BaseService {
1390
1698
 
1391
1699
  switch (step.opType) {
1392
1700
  case 'add': {
1393
- const nodes = step.nodes ?? [];
1701
+ const items = step.diff ?? [];
1394
1702
  if (reverse) {
1395
1703
  // 撤销 add:把当时加入的节点删除
1396
- for (const n of nodes) {
1397
- 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);
1398
1707
  if (existing) {
1399
1708
  await this.remove(existing, commonOpts);
1400
1709
  }
1401
1710
  }
1402
1711
  } else {
1403
- // 重做 add:按记录的 indexMap 把节点重新插回父容器
1404
- const parent = this.getNodeById(step.parentId!, false) as MContainer | null;
1405
- if (parent) {
1406
- // 按目标 index 升序逐个插入,先小后大避免索引漂移
1407
- const sorted = [...nodes].sort((a, b) => (step.indexMap?.[a.id] ?? 0) - (step.indexMap?.[b.id] ?? 0));
1408
- for (const n of sorted) {
1409
- const idx = step.indexMap?.[n.id];
1410
- if (parent.items) {
1411
- if (typeof idx === 'number' && idx >= 0 && idx < parent.items.length) {
1412
- parent.items.splice(idx, 0, cloneDeep(n));
1413
- } else {
1414
- parent.items.push(cloneDeep(n));
1415
- }
1416
- await stage?.add({
1417
- config: cloneDeep(n),
1418
- parent: cloneDeep(parent),
1419
- parentId: parent.id,
1420
- root: cloneDeep(root),
1421
- });
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));
1422
1723
  }
1724
+ await stage?.add({
1725
+ config: cloneDeep(newSchema),
1726
+ parent: cloneDeep(parent),
1727
+ parentId: parent.id,
1728
+ root: cloneDeep(root),
1729
+ });
1423
1730
  }
1424
1731
  }
1425
1732
  }
1426
1733
  break;
1427
1734
  }
1428
1735
  case 'remove': {
1429
- const items = step.removedItems ?? [];
1736
+ const items = step.diff ?? [];
1430
1737
  if (reverse) {
1431
1738
  // 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
1432
- const sorted = [...items].sort((a, b) => a.index - b.index);
1433
- 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;
1434
1742
  const parent = this.getNodeById(parentId, false) as MContainer | null;
1435
1743
  if (parent?.items) {
1436
- parent.items.splice(index, 0, cloneDeep(node));
1744
+ parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
1437
1745
  await stage?.add({
1438
- config: cloneDeep(node),
1746
+ config: cloneDeep(oldSchema),
1439
1747
  parent: cloneDeep(parent),
1440
1748
  parentId,
1441
1749
  root: cloneDeep(root),
@@ -1444,8 +1752,9 @@ class Editor extends BaseService {
1444
1752
  }
1445
1753
  } else {
1446
1754
  // 重做 remove:再删一次
1447
- for (const { node } of items) {
1448
- 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);
1449
1758
  if (existing) {
1450
1759
  await this.remove(existing, commonOpts);
1451
1760
  }
@@ -1454,27 +1763,31 @@ class Editor extends BaseService {
1454
1763
  break;
1455
1764
  }
1456
1765
  case 'update': {
1457
- const items = step.updatedItems ?? [];
1766
+ const items = step.diff ?? [];
1458
1767
  // 优先按 changeRecords 局部 patch:仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
1459
1768
  // 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
1460
- const configs = items.map(({ oldNode, newNode, changeRecords }) => {
1461
- if (changeRecords?.length) {
1462
- const sourceForValues = reverse ? oldNode : newNode;
1463
- // 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
1464
- // 后续 update -> mergeWith 会与现有节点深合并,patch 中未涉及的字段不会被改动。
1465
- const patch: MNode = { id: newNode.id, type: newNode.type };
1466
- for (const record of changeRecords) {
1467
- if (!record.propPath) {
1468
- // 没有 propPath 视为整节点替换
1469
- 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);
1470
1786
  }
1471
- const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
1472
- setValueByKeyPath(record.propPath, value, patch);
1787
+ return patch;
1473
1788
  }
1474
- return patch;
1475
- }
1476
- return cloneDeep(reverse ? oldNode : newNode);
1477
- });
1789
+ return cloneDeep(reverse ? oldNode : newNode);
1790
+ });
1478
1791
  if (configs.length) {
1479
1792
  await this.update(configs, { doNotPushHistory: true });
1480
1793
  }