@tmagic/editor 1.8.0-beta.0 → 1.8.0-beta.2

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 (138) hide show
  1. package/dist/es/Editor.vue_vue_type_script_setup_true_lang.js +11 -2
  2. package/dist/es/components/CodeBlockEditor.vue_vue_type_script_setup_true_lang.js +18 -110
  3. package/dist/es/components/CodeParams.vue_vue_type_script_setup_true_lang.js +6 -0
  4. package/dist/es/components/CompareForm.js +5 -0
  5. package/dist/es/components/CompareForm.vue_vue_type_script_setup_true_lang.js +167 -0
  6. package/dist/es/fields/Code.vue_vue_type_script_setup_true_lang.js +19 -3
  7. package/dist/es/fields/CodeSelect.vue_vue_type_script_setup_true_lang.js +18 -2
  8. package/dist/es/fields/CodeSelectCol.vue_vue_type_script_setup_true_lang.js +37 -6
  9. package/dist/es/fields/DataSourceFieldSelect/FieldSelect.vue_vue_type_script_setup_true_lang.js +3 -1
  10. package/dist/es/fields/DataSourceFieldSelect/Index.vue_vue_type_script_setup_true_lang.js +12 -3
  11. package/dist/es/fields/DataSourceFields.vue_vue_type_script_setup_true_lang.js +14 -6
  12. package/dist/es/fields/DataSourceMethodSelect.vue_vue_type_script_setup_true_name_true_lang.js +3 -1
  13. package/dist/es/fields/DataSourceMethods.vue_vue_type_script_setup_true_lang.js +15 -7
  14. package/dist/es/fields/DataSourceMocks.vue_vue_type_script_setup_true_lang.js +14 -6
  15. package/dist/es/fields/DataSourceSelect.vue_vue_type_script_setup_true_lang.js +3 -1
  16. package/dist/es/fields/EventSelect.vue_vue_type_script_setup_true_lang.js +58 -11
  17. package/dist/es/fields/KeyValue.vue_vue_type_script_setup_true_lang.js +11 -6
  18. package/dist/es/fields/StyleSetter/Index.vue_vue_type_script_setup_true_lang.js +8 -2
  19. package/dist/es/fields/StyleSetter/components/Border.vue_vue_type_script_setup_true_lang.js +10 -2
  20. package/dist/es/fields/StyleSetter/components/Box.vue_vue_type_script_setup_true_lang.js +3 -1
  21. package/dist/es/fields/StyleSetter/pro/Background.vue_vue_type_script_setup_true_lang.js +10 -2
  22. package/dist/es/fields/StyleSetter/pro/Border.vue_vue_type_script_setup_true_lang.js +16 -3
  23. package/dist/es/fields/StyleSetter/pro/Font.vue_vue_type_script_setup_true_lang.js +10 -2
  24. package/dist/es/fields/StyleSetter/pro/Layout.vue_vue_type_script_setup_true_lang.js +14 -2
  25. package/dist/es/fields/StyleSetter/pro/Position.vue_vue_type_script_setup_true_lang.js +10 -2
  26. package/dist/es/fields/StyleSetter/pro/Transform.vue_vue_type_script_setup_true_lang.js +10 -2
  27. package/dist/es/fields/UISelect.vue_vue_type_script_setup_true_lang.js +8 -5
  28. package/dist/es/hooks/use-code-block-edit.js +2 -2
  29. package/dist/es/hooks/use-stage.js +15 -7
  30. package/dist/es/index.js +3 -1
  31. package/dist/es/initService.js +6 -6
  32. package/dist/es/layouts/CodeEditor.vue_vue_type_script_setup_true_lang.js +4 -0
  33. package/dist/es/layouts/Framework.vue_vue_type_script_setup_true_lang.js +12 -7
  34. package/dist/es/layouts/NavMenu.vue_vue_type_script_setup_true_lang.js +8 -0
  35. package/dist/es/layouts/history-list/Bucket.js +5 -0
  36. package/dist/es/layouts/history-list/Bucket.vue_vue_type_script_setup_true_lang.js +84 -0
  37. package/dist/es/layouts/history-list/CodeBlockTab.js +5 -0
  38. package/dist/es/layouts/history-list/CodeBlockTab.vue_vue_type_script_setup_true_lang.js +62 -0
  39. package/dist/es/layouts/history-list/DataSourceTab.js +5 -0
  40. package/dist/es/layouts/history-list/DataSourceTab.vue_vue_type_script_setup_true_lang.js +62 -0
  41. package/dist/es/layouts/history-list/GroupRow.js +5 -0
  42. package/dist/es/layouts/history-list/GroupRow.vue_vue_type_script_setup_true_lang.js +180 -0
  43. package/dist/es/layouts/history-list/HistoryDiffDialog.js +5 -0
  44. package/dist/es/layouts/history-list/HistoryDiffDialog.vue_vue_type_script_setup_true_lang.js +195 -0
  45. package/dist/es/layouts/history-list/HistoryListPanel.js +5 -0
  46. package/dist/es/layouts/history-list/HistoryListPanel.vue_vue_type_script_setup_true_lang.js +270 -0
  47. package/dist/es/layouts/history-list/InitialRow.js +5 -0
  48. package/dist/es/layouts/history-list/InitialRow.vue_vue_type_script_setup_true_lang.js +48 -0
  49. package/dist/es/layouts/history-list/PageTab.js +5 -0
  50. package/dist/es/layouts/history-list/PageTab.vue_vue_type_script_setup_true_lang.js +99 -0
  51. package/dist/es/layouts/history-list/composables.js +154 -0
  52. package/dist/es/layouts/sidebar/layer/use-node-status.js +38 -4
  53. package/dist/es/services/BaseService.js +31 -68
  54. package/dist/es/services/codeBlock.js +205 -8
  55. package/dist/es/services/componentList.js +2 -2
  56. package/dist/es/services/dataSource.js +217 -8
  57. package/dist/es/services/dep.js +21 -9
  58. package/dist/es/services/editor.js +350 -160
  59. package/dist/es/services/events.js +2 -2
  60. package/dist/es/services/history.js +407 -12
  61. package/dist/es/services/keybinding.js +2 -2
  62. package/dist/es/services/props.js +8 -8
  63. package/dist/es/services/stageOverlay.js +2 -2
  64. package/dist/es/services/storage.js +2 -2
  65. package/dist/es/services/ui.js +2 -2
  66. package/dist/es/style.css +347 -1
  67. package/dist/es/utils/code-block.js +122 -0
  68. package/dist/es/utils/content-menu.js +4 -5
  69. package/dist/es/utils/dep/worker.js +1 -1
  70. package/dist/es/utils/editor.js +3 -3
  71. package/dist/es/utils/undo-redo.js +20 -0
  72. package/dist/style.css +347 -1
  73. package/dist/tmagic-editor.umd.cjs +3273 -766
  74. package/package.json +7 -7
  75. package/src/Editor.vue +7 -0
  76. package/src/components/CodeBlockEditor.vue +20 -109
  77. package/src/components/CodeParams.vue +6 -0
  78. package/src/components/CompareForm.vue +225 -0
  79. package/src/editorProps.ts +2 -0
  80. package/src/fields/Code.vue +19 -3
  81. package/src/fields/CodeSelect.vue +17 -1
  82. package/src/fields/CodeSelectCol.vue +30 -2
  83. package/src/fields/DataSourceFieldSelect/FieldSelect.vue +7 -1
  84. package/src/fields/DataSourceFieldSelect/Index.vue +9 -2
  85. package/src/fields/DataSourceFields.vue +12 -3
  86. package/src/fields/DataSourceMethodSelect.vue +4 -1
  87. package/src/fields/DataSourceMethods.vue +14 -4
  88. package/src/fields/DataSourceMocks.vue +13 -3
  89. package/src/fields/DataSourceSelect.vue +4 -1
  90. package/src/fields/EventSelect.vue +57 -8
  91. package/src/fields/KeyValue.vue +17 -4
  92. package/src/fields/StyleSetter/Index.vue +6 -0
  93. package/src/fields/StyleSetter/components/Border.vue +15 -1
  94. package/src/fields/StyleSetter/components/Box.vue +2 -0
  95. package/src/fields/StyleSetter/pro/Background.vue +15 -1
  96. package/src/fields/StyleSetter/pro/Border.vue +24 -2
  97. package/src/fields/StyleSetter/pro/Font.vue +15 -1
  98. package/src/fields/StyleSetter/pro/Layout.vue +17 -1
  99. package/src/fields/StyleSetter/pro/Position.vue +15 -1
  100. package/src/fields/StyleSetter/pro/Transform.vue +15 -1
  101. package/src/fields/UISelect.vue +5 -2
  102. package/src/hooks/use-code-block-edit.ts +5 -2
  103. package/src/hooks/use-stage.ts +23 -7
  104. package/src/index.ts +1 -0
  105. package/src/layouts/CodeEditor.vue +15 -0
  106. package/src/layouts/Framework.vue +10 -5
  107. package/src/layouts/NavMenu.vue +9 -0
  108. package/src/layouts/history-list/Bucket.vue +99 -0
  109. package/src/layouts/history-list/CodeBlockTab.vue +61 -0
  110. package/src/layouts/history-list/DataSourceTab.vue +61 -0
  111. package/src/layouts/history-list/GroupRow.vue +207 -0
  112. package/src/layouts/history-list/HistoryDiffDialog.vue +212 -0
  113. package/src/layouts/history-list/HistoryListPanel.vue +271 -0
  114. package/src/layouts/history-list/InitialRow.vue +41 -0
  115. package/src/layouts/history-list/PageTab.vue +94 -0
  116. package/src/layouts/history-list/composables.ts +195 -0
  117. package/src/layouts/sidebar/layer/use-node-status.ts +64 -12
  118. package/src/services/BaseService.ts +35 -86
  119. package/src/services/codeBlock.ts +256 -5
  120. package/src/services/dataSource.ts +257 -6
  121. package/src/services/dep.ts +32 -9
  122. package/src/services/editor.ts +454 -171
  123. package/src/services/history.ts +452 -8
  124. package/src/services/props.ts +2 -2
  125. package/src/theme/history-list-panel.scss +423 -0
  126. package/src/theme/theme.scss +1 -0
  127. package/src/type.ts +179 -6
  128. package/src/utils/code-block.ts +150 -0
  129. package/src/utils/content-menu.ts +8 -5
  130. package/src/utils/dep/worker.ts +5 -3
  131. package/src/utils/editor.ts +4 -1
  132. package/src/utils/index.ts +1 -0
  133. package/src/utils/undo-redo.ts +23 -0
  134. package/types/index.d.ts +2107 -810
  135. package/dist/es/utils/compose.js +0 -44
  136. package/dist/es/utils/editor-history.js +0 -100
  137. package/src/utils/compose.ts +0 -52
  138. package/src/utils/editor-history.ts +0 -138
@@ -23,7 +23,7 @@ import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions }
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, isPage, isPageFragment } from '@tmagic/utils';
26
+ import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils';
27
27
 
28
28
  import BaseService from '@editor/services//BaseService';
29
29
  import propsService from '@editor/services//props';
@@ -62,27 +62,40 @@ import {
62
62
  setLayout,
63
63
  toggleFixedPosition,
64
64
  } from '@editor/utils/editor';
65
- import type { HistoryOpContext } from '@editor/utils/editor-history';
66
- import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
67
65
  import { beforePaste, getAddParent } from '@editor/utils/operator';
68
66
 
69
- /**
70
- * 经过 BaseService 的插件 / 中间件包装后,源方法的最后一个形参可能被注入为 dispatch 函数
71
- * 当 options 形参位置被注入为函数(或为 null)时,将其归一为空对象,避免后续逻辑误读
72
- */
73
- const safeOptions = <T extends object>(options: unknown): T => {
74
- const empty = {};
75
- if (!options || typeof options === 'function') return empty as T;
76
- return options as T;
77
- };
67
+ type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
78
68
 
79
69
  /**
80
- * 经过 BaseService 的插件 / 中间件包装后,源方法的形参可能被注入为 dispatch 函数
81
- * parent 形参位置被注入为函数(或为空值)时,归一为 null,由调用方继续走默认 parent 逻辑
70
+ * 给「回滚」生成的新 step 用的简短描述生成器。
71
+ * UI `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。
82
72
  */
83
- const safeParent = (parent: unknown): MContainer | null => {
84
- if (!parent || typeof parent === 'function') return null;
85
- return parent as MContainer;
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
+ }
86
99
  };
87
100
 
88
101
  class Editor extends BaseService {
@@ -101,7 +114,6 @@ class Editor extends BaseService {
101
114
  disabledMultiSelect: false,
102
115
  alwaysMultiSelect: false,
103
116
  });
104
- private isHistoryStateChange = false;
105
117
  private selectionBeforeOp: Id[] | null = null;
106
118
 
107
119
  constructor() {
@@ -212,7 +224,7 @@ class Editor extends BaseService {
212
224
  * 只有容器拥有布局
213
225
  */
214
226
  public async getLayout(parent: MNode, node?: MNode | null): Promise<Layout> {
215
- if (node && typeof node !== 'function' && isFixed(node.style || {})) return Layout.FIXED;
227
+ if (node && isFixed(node.style || {})) return Layout.FIXED;
216
228
 
217
229
  if (parent.layout) {
218
230
  return parent.layout;
@@ -388,16 +400,14 @@ class Editor extends BaseService {
388
400
  * @param options 可选配置
389
401
  * @param options.doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
390
402
  * @param options.doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
403
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
391
404
  * @returns 添加后的节点
392
405
  */
393
406
  public async add(
394
407
  addNode: AddMNode | MNode[],
395
408
  parent?: MContainer | null,
396
- options?: DslOpOptions,
409
+ { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
397
410
  ): Promise<MNode | MNode[]> {
398
- const safeParentNode = safeParent(parent);
399
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
400
-
401
411
  this.captureSelectionBeforeOp();
402
412
 
403
413
  const stage = this.get('stage');
@@ -420,7 +430,7 @@ class Editor extends BaseService {
420
430
  if ((isPage(node) || isPageFragment(node)) && root) {
421
431
  return this.doAdd(node, root);
422
432
  }
423
- const parentNode = safeParentNode ?? getAddParent(node);
433
+ const parentNode = parent ?? getAddParent(node);
424
434
  if (!parentNode) throw new Error('未找到父元素');
425
435
  return this.doAdd(node, parentNode);
426
436
  }),
@@ -455,20 +465,25 @@ class Editor extends BaseService {
455
465
 
456
466
  if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
457
467
  const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
458
- this.pushOpHistory(
459
- 'add',
460
- {
461
- nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
462
- parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
463
- indexMap: Object.fromEntries(
464
- newNodes.map((n) => {
465
- const p = this.getParentById(n.id, false) as MContainer;
466
- return [n.id, p ? getNodeIndex(n.id, p) : -1];
467
- }),
468
- ),
469
- },
470
- { name: pageForOp?.name || '', id: pageForOp!.id },
471
- );
468
+ 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 },
482
+ historyDescription,
483
+ );
484
+ } else {
485
+ this.selectionBeforeOp = null;
486
+ }
472
487
  }
473
488
 
474
489
  this.emit('add', newNodes);
@@ -476,9 +491,10 @@ class Editor extends BaseService {
476
491
  return Array.isArray(addNode) ? newNodes : newNodes[0];
477
492
  }
478
493
 
479
- public async doRemove(node: MNode, options?: DslOpOptions): Promise<void> {
480
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
481
-
494
+ public async doRemove(
495
+ node: MNode,
496
+ { doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
497
+ ): Promise<void> {
482
498
  const root = this.get('root');
483
499
  if (!root) throw new Error('root不能为空');
484
500
 
@@ -557,10 +573,12 @@ class Editor extends BaseService {
557
573
  * @param options 可选配置
558
574
  * @param options.doNotSelect 删除后是否不更新当前选中节点(默认 false,删除后会选中父节点或首个页面)
559
575
  * @param options.doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
576
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
560
577
  */
561
- public async remove(nodeOrNodeList: MNode | MNode[], options?: DslOpOptions): Promise<void> {
562
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
563
-
578
+ public async remove(
579
+ nodeOrNodeList: MNode | MNode[],
580
+ { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
581
+ ): Promise<void> {
564
582
  this.captureSelectionBeforeOp();
565
583
 
566
584
  const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
@@ -587,7 +605,11 @@ class Editor extends BaseService {
587
605
  await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
588
606
 
589
607
  if (removedItems.length > 0 && pageForOp) {
590
- this.pushOpHistory('remove', { removedItems }, pageForOp);
608
+ if (!doNotPushHistory) {
609
+ this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription);
610
+ } else {
611
+ this.selectionBeforeOp = null;
612
+ }
591
613
  }
592
614
 
593
615
  this.emit('remove', nodes);
@@ -668,34 +690,60 @@ class Editor extends BaseService {
668
690
  * 更新节点
669
691
  * update后会触发依赖收集,收集完后会掉stage.update方法
670
692
  * @param config 新的节点配置,配置中需要有id信息
693
+ * @param data 额外数据
694
+ * @param data.changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 changeRecordList)
695
+ * @param data.changeRecordList 多节点 form 端变更记录列表,按 config 数组同序对应每个节点;优先级高于 changeRecords
696
+ * @param data.doNotPushHistory 是否不写入历史记录(默认 false)
697
+ * @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示(不影响 undo/redo 行为)
671
698
  * @returns 更新后的节点配置
672
699
  */
673
700
  public async update(
674
701
  config: MNode | MNode[],
675
- data: { changeRecords?: ChangeRecord[] } = {},
702
+ data: {
703
+ changeRecords?: ChangeRecord[];
704
+ changeRecordList?: ChangeRecord[][];
705
+ doNotPushHistory?: boolean;
706
+ historyDescription?: string;
707
+ } = {},
676
708
  ): Promise<MNode | MNode[]> {
677
709
  this.captureSelectionBeforeOp();
678
710
 
711
+ const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription } = data;
712
+
679
713
  const nodes = Array.isArray(config) ? config : [config];
680
714
 
681
- const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
715
+ // 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
716
+ // 否则同一份 changeRecords 会被复用到每个节点上,nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
717
+ const updateData = await Promise.all(
718
+ nodes.map((node, index) => {
719
+ const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
720
+ return this.doUpdate(node, { changeRecords: recordsForNode });
721
+ }),
722
+ );
682
723
 
683
724
  if (updateData[0].oldNode?.type !== NodeType.ROOT) {
684
725
  const curNodes = this.get('nodes');
685
- if (!this.isHistoryStateChange && curNodes.length) {
686
- const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
687
- this.pushOpHistory(
688
- 'update',
689
- {
690
- updatedItems: updateData.map((d) => ({
691
- oldNode: cloneDeep(d.oldNode),
692
- newNode: cloneDeep(toRaw(d.newNode)),
693
- })),
694
- },
695
- { name: pageForOp?.name || '', id: pageForOp!.id },
696
- );
726
+ if (curNodes.length) {
727
+ if (!doNotPushHistory) {
728
+ const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
729
+ this.pushOpHistory(
730
+ 'update',
731
+ {
732
+ updatedItems: updateData.map((d) => ({
733
+ oldNode: cloneDeep(d.oldNode),
734
+ newNode: cloneDeep(toRaw(d.newNode)),
735
+ // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
736
+ // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
737
+ changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
738
+ })),
739
+ },
740
+ { name: pageForOp?.name || '', id: pageForOp!.id },
741
+ historyDescription,
742
+ );
743
+ } else {
744
+ this.selectionBeforeOp = null;
745
+ }
697
746
  }
698
- this.isHistoryStateChange = false;
699
747
  }
700
748
 
701
749
  this.emit('update', updateData);
@@ -709,11 +757,14 @@ class Editor extends BaseService {
709
757
  * @param options 可选配置
710
758
  * @param options.doNotSelect 排序后是否不更新当前选中节点(默认 false)
711
759
  * @param options.doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
760
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
712
761
  * @returns void
713
762
  */
714
- public async sort(id1: Id, id2: Id, options?: DslOpOptions): Promise<void> {
715
- const { doNotSelect = false } = safeOptions<DslOpOptions>(options);
716
-
763
+ public async sort(
764
+ id1: Id,
765
+ id2: Id,
766
+ { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
767
+ ): Promise<void> {
717
768
  this.captureSelectionBeforeOp();
718
769
 
719
770
  const root = this.get('root');
@@ -732,7 +783,7 @@ class Editor extends BaseService {
732
783
 
733
784
  parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
734
785
 
735
- await this.update(parent);
786
+ await this.update(parent, { doNotPushHistory });
736
787
  if (!doNotSelect) {
737
788
  await this.select(node);
738
789
  }
@@ -779,15 +830,14 @@ class Editor extends BaseService {
779
830
  * @param options 可选配置
780
831
  * @param options.doNotSelect 粘贴后是否不更新当前选中节点(默认 false)
781
832
  * @param options.doNotSwitchPage 粘贴后是否不切换当前页面(默认 false;跨页粘贴时为 true 会跳过页面切换)
833
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
782
834
  * @returns 添加后的组件节点配置
783
835
  */
784
836
  public async paste(
785
837
  position: PastePosition = {},
786
838
  collectorOptions?: TargetOptions,
787
- options?: DslOpOptions,
839
+ { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
788
840
  ): Promise<MNode | MNode[] | void> {
789
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
790
-
791
841
  const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
792
842
  if (!Array.isArray(config)) return;
793
843
 
@@ -807,7 +857,7 @@ class Editor extends BaseService {
807
857
  propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
808
858
  }
809
859
 
810
- return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage });
860
+ return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage, doNotPushHistory });
811
861
  }
812
862
 
813
863
  public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@@ -838,17 +888,19 @@ class Editor extends BaseService {
838
888
  * @param options 可选配置
839
889
  * @param options.doNotSelect 居中后是否不更新当前选中节点(默认 false)
840
890
  * @param options.doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style,方法内为空操作;保留以与其它 DSL 操作 API 一致)
891
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
841
892
  * @returns 当前组件节点配置
842
893
  */
843
- public async alignCenter(config: MNode | MNode[], options?: DslOpOptions): Promise<MNode | MNode[]> {
844
- const { doNotSelect = false } = safeOptions<DslOpOptions>(options);
845
-
894
+ public async alignCenter(
895
+ config: MNode | MNode[],
896
+ { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
897
+ ): Promise<MNode | MNode[]> {
846
898
  const nodes = Array.isArray(config) ? config : [config];
847
899
  const stage = this.get('stage');
848
900
 
849
901
  const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
850
902
 
851
- const newNode = await this.update(newNodes);
903
+ const newNode = await this.update(newNodes, { doNotPushHistory });
852
904
 
853
905
  if (!doNotSelect) {
854
906
  if (newNodes.length > 1) {
@@ -864,8 +916,10 @@ class Editor extends BaseService {
864
916
  /**
865
917
  * 移动当前选中节点位置
866
918
  * @param offset 偏移量
919
+ * @param options 可选配置
920
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
867
921
  */
868
- public async moveLayer(offset: number | LayerOffset): Promise<void> {
922
+ public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise<void> {
869
923
  this.captureSelectionBeforeOp();
870
924
 
871
925
  const root = this.get('root');
@@ -902,94 +956,116 @@ class Editor extends BaseService {
902
956
  });
903
957
 
904
958
  this.addModifiedNodeId(parent.id);
905
- const pageForOp = this.getNodeInfo(node.id, false).page;
906
- this.pushOpHistory(
907
- 'update',
908
- {
909
- updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
910
- },
911
- { name: pageForOp?.name || '', id: pageForOp!.id },
912
- );
959
+ if (!doNotPushHistory) {
960
+ const pageForOp = this.getNodeInfo(node.id, false).page;
961
+ this.pushOpHistory(
962
+ 'update',
963
+ {
964
+ updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
965
+ },
966
+ { name: pageForOp?.name || '', id: pageForOp!.id },
967
+ );
968
+ } else {
969
+ this.selectionBeforeOp = null;
970
+ }
913
971
 
914
972
  this.emit('move-layer', offset);
915
973
  }
916
974
 
917
975
  /**
918
- * 移动到指定容器中
919
- * @param config 需要移动的节点
976
+ * 移动一个或多个节点到指定容器中。
977
+ *
978
+ * 多选场景(config 是数组)只会产生一条历史记录,
979
+ * `updatedItems` 涵盖所有源父容器 + 目标容器的前后快照。
980
+ * 这避免了"多选移动到某容器"在历史栈里被切成 N 条记录。
981
+ *
982
+ * @param config 需要移动的节点(或节点数组,各项需带 id;style 等字段会与原节点合并)
920
983
  * @param targetId 容器ID
921
984
  * @param options 可选配置
922
985
  * @param options.doNotSelect 移动后是否不更新当前选中节点(默认 false)
923
986
  * @param options.doNotSwitchPage 移动后是否不切换当前页面(默认 false;目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
987
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
924
988
  */
925
- public async moveToContainer(config: MNode, targetId: Id, options?: DslOpOptions): Promise<MNode | undefined> {
926
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
989
+ public async moveToContainer(
990
+ config: MNode | MNode[],
991
+ targetId: Id,
992
+ { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
993
+ ): Promise<MNode | MNode[]> {
994
+ const isBatch = Array.isArray(config);
995
+ const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
996
+
997
+ if (configs.length === 0) {
998
+ throw new Error('没有可移动的节点');
999
+ }
927
1000
 
928
1001
  this.captureSelectionBeforeOp();
929
1002
 
930
- const root = this.get('root');
931
- const { node, parent, page: pageForOp } = this.getNodeInfo(config.id, false);
932
1003
  const target = this.getNodeById(targetId, false) as MContainer;
933
1004
 
934
- const stage = this.get('stage');
935
- if (root && node && parent && stage) {
936
- const oldSourceParent = cloneDeep(toRaw(parent));
937
- const oldTarget = cloneDeep(toRaw(target));
938
-
939
- const index = getNodeIndex(node.id, parent);
940
- parent.items?.splice(index, 1);
941
-
942
- await stage.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
943
-
944
- const layout = await this.getLayout(target);
945
-
946
- const newConfig = mergeWith(cloneDeep(node), config, (_objValue, srcValue) => {
947
- if (Array.isArray(srcValue)) {
948
- return srcValue;
949
- }
950
- });
951
- newConfig.style = getInitPositionStyle(newConfig.style, layout);
1005
+ if (!target) {
1006
+ throw new Error('目标容器不存在');
1007
+ }
952
1008
 
953
- target.items.push(newConfig);
1009
+ const root = this.get('root');
1010
+ const stage = this.get('stage');
954
1011
 
955
- // 目标容器是否在非当前页面:选中目标会触发当前页面切换
956
- const targetWouldSwitchPage = this.isOnDifferentPage(target);
957
- const skipSelect = doNotSelect || (doNotSwitchPage && targetWouldSwitchPage);
1012
+ if (!root || !stage) {
1013
+ throw new Error('root 或 stage为空');
1014
+ }
958
1015
 
959
- if (!skipSelect) {
960
- await stage.select(targetId);
961
- }
1016
+ // 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
1017
+ const moves: MoveItem[] = [];
1018
+ for (const { id } of configs) {
1019
+ const { node, parent, page } = this.getNodeInfo(id, false);
1020
+ if (!node || !parent) continue;
1021
+ moves.push({ node, parent, pageForOp: page ? { name: page.name || '', id: page.id } : null });
1022
+ }
962
1023
 
963
- const targetParent = this.getParentById(target.id);
964
- await stage.update({
965
- config: cloneDeep(target),
966
- parentId: targetParent?.id,
967
- root: cloneDeep(root),
968
- });
1024
+ if (moves.length === 0) {
1025
+ throw new Error('没有可移动的节点');
1026
+ }
969
1027
 
970
- if (!skipSelect) {
971
- await this.select(newConfig);
972
- stage.select(newConfig.id);
1028
+ // 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
1029
+ const beforeSnapshots = new Map<Id, MNode>();
1030
+ beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
1031
+ for (const { parent } of moves) {
1032
+ if (!beforeSnapshots.has(parent.id)) {
1033
+ beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
973
1034
  }
1035
+ }
974
1036
 
975
- this.addModifiedNodeId(target.id);
976
- this.addModifiedNodeId(parent.id);
977
- this.pushOpHistory(
978
- 'update',
979
- {
980
- updatedItems: [
981
- { oldNode: oldSourceParent, newNode: cloneDeep(toRaw(parent)) },
982
- { oldNode: oldTarget, newNode: cloneDeep(toRaw(target)) },
983
- ],
984
- },
985
- { name: pageForOp?.name || '', id: pageForOp!.id },
986
- );
987
-
988
- return newConfig;
1037
+ let newConfigs: MNode[] = [];
1038
+
1039
+ const moveNodes = moves.map(({ node }) => node);
1040
+ await this.remove(moveNodes, { doNotPushHistory: true, doNotSelect, doNotSwitchPage: true });
1041
+
1042
+ newConfigs = (await this.add(moveNodes, target, {
1043
+ doNotPushHistory: true,
1044
+ doNotSelect,
1045
+ doNotSwitchPage,
1046
+ })) as MNode[];
1047
+
1048
+ if (!doNotPushHistory) {
1049
+ // 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
1050
+ const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
1051
+ oldNode,
1052
+ newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
1053
+ }));
1054
+ const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
1055
+ this.pushOpHistory('update', { updatedItems }, historyPage);
1056
+ } else {
1057
+ this.selectionBeforeOp = null;
989
1058
  }
1059
+
1060
+ return isBatch ? newConfigs : newConfigs[0];
990
1061
  }
991
1062
 
992
- public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
1063
+ public async dragTo(
1064
+ config: MNode | MNode[],
1065
+ targetParent: MContainer,
1066
+ targetIndex: number,
1067
+ { doNotPushHistory = false }: DslOpOptions = {},
1068
+ ) {
993
1069
  this.captureSelectionBeforeOp();
994
1070
 
995
1071
  if (!targetParent || !Array.isArray(targetParent.items)) return;
@@ -1049,8 +1125,12 @@ class Editor extends BaseService {
1049
1125
  updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
1050
1126
  }
1051
1127
  }
1052
- const pageForOp = this.getNodeInfo(configs[0].id, false).page;
1053
- this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
1128
+ if (!doNotPushHistory) {
1129
+ const pageForOp = this.getNodeInfo(configs[0].id, false).page;
1130
+ this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
1131
+ } else {
1132
+ this.selectionBeforeOp = null;
1133
+ }
1054
1134
 
1055
1135
  this.emit('drag-to', { targetIndex, configs, targetParent });
1056
1136
  }
@@ -1079,14 +1159,140 @@ class Editor extends BaseService {
1079
1159
  return value;
1080
1160
  }
1081
1161
 
1082
- public async move(left: number, top: number) {
1162
+ /**
1163
+ * 「回滚」指定页面历史步骤(类 git revert 语义):
1164
+ * - 不动原始历史栈结构(不移动 cursor、不丢弃任何步骤);
1165
+ * - 取出 `index` 对应的 step,**反向应用**一次(add→remove / remove→add / update→旧值);
1166
+ * - 把这次反向应用作为一条**新步骤**追加到栈顶,可被普通 undo / redo。
1167
+ *
1168
+ * 与 `gotoPageStep`(类 git reset)的区别在于此操作**不丢弃**目标之后的历史。
1169
+ * 与 `applyHistoryOp(reverse=true)` 的区别在于:本方法**不带** `doNotPushHistory`,
1170
+ * 反向应用会以一条新 step 入栈;并且不实施 step 中保存的选区与 modifiedNodeIds 状态,
1171
+ * 选区由用户当前位置决定,符合"新提交"语义。
1172
+ *
1173
+ * 仅对处于「已应用」状态的步骤生效——未应用的步骤本身就不存在于当前 DSL 中,反向无意义。
1174
+ *
1175
+ * @param index 目标 step 在所属页面栈中的索引(0 为最早),通常由历史面板传入
1176
+ * @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
1177
+ */
1178
+ public async revertPageStep(index: number): Promise<StepValue | null> {
1179
+ const list = historyService.getPageStepList();
1180
+ const entry = list[index];
1181
+ if (!entry?.applied) return null;
1182
+
1183
+ const { step } = entry;
1184
+ const root = this.get('root');
1185
+ if (!root) return null;
1186
+
1187
+ // 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
1188
+ let revertedStep: StepValue | null = null;
1189
+ const captureRevert = (s: StepValue) => {
1190
+ revertedStep = s;
1191
+ };
1192
+ historyService.once('change', captureRevert);
1193
+
1194
+ const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
1195
+ // revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
1196
+ const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription } as const;
1197
+
1198
+ try {
1199
+ switch (step.opType) {
1200
+ case 'add': {
1201
+ // 原本是新增 → revert 即删除当时被加入的节点
1202
+ const nodes = step.nodes ?? [];
1203
+ for (const n of nodes) {
1204
+ const existing = this.getNodeById(n.id, false);
1205
+ if (existing) {
1206
+ await this.remove(existing, opts);
1207
+ }
1208
+ }
1209
+ break;
1210
+ }
1211
+ case 'remove': {
1212
+ // 原本是删除 → revert 即把节点按原父容器加回来。
1213
+ // 按原 index 升序逐个插回,先小后大避免索引漂移。
1214
+ const items = step.removedItems ?? [];
1215
+ const sorted = [...items].sort((a, b) => a.index - b.index);
1216
+ for (const { node, parentId } of sorted) {
1217
+ const parent = this.getNodeById(parentId, false) as MContainer | null;
1218
+ if (parent) {
1219
+ await this.add([cloneDeep(node)] as MNode[], parent, opts);
1220
+ }
1221
+ }
1222
+ break;
1223
+ }
1224
+ case 'update': {
1225
+ // 原本是更新 → revert 即把 oldNode 的值写回;
1226
+ // 优先按 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);
1235
+ }
1236
+ const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
1237
+ setValueByKeyPath(record.propPath, value, patch);
1238
+ }
1239
+ return patch;
1240
+ }
1241
+ return cloneDeep(oldNode);
1242
+ });
1243
+ if (configs.length) {
1244
+ await this.update(configs, { historyDescription });
1245
+ }
1246
+ break;
1247
+ }
1248
+ }
1249
+ } finally {
1250
+ historyService.off('change', captureRevert);
1251
+ }
1252
+
1253
+ // 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
1254
+ const page = toRaw(this.get('page'));
1255
+ if (page) {
1256
+ this.emit('history-change', page as MPage | MPageFragment);
1257
+ }
1258
+
1259
+ return revertedStep;
1260
+ }
1261
+
1262
+ /**
1263
+ * 跳转当前页面历史栈到指定游标位置。
1264
+ *
1265
+ * `targetCursor` 与 `UndoRedo.getCursor()` 同义:表示"已应用步骤数量",
1266
+ * 取值范围 `[0, length]`。当目标 < 当前游标时循环 undo,否则循环 redo。
1267
+ * 通常由历史面板传入「点击的 step.index + 1」作为目标。
1268
+ *
1269
+ * @returns 实际移动到的最终游标位置
1270
+ */
1271
+ public async gotoPageStep(targetCursor: number): Promise<number> {
1272
+ let cursor = historyService.getPageCursor();
1273
+ const { length } = historyService.getPageStepList();
1274
+ const target = Math.max(0, Math.min(targetCursor, length));
1275
+ while (cursor > target) {
1276
+ const step = await this.undo();
1277
+ if (!step) break;
1278
+ cursor -= 1;
1279
+ }
1280
+ while (cursor < target) {
1281
+ const step = await this.redo();
1282
+ if (!step) break;
1283
+ cursor += 1;
1284
+ }
1285
+ return cursor;
1286
+ }
1287
+
1288
+ public async move(left: number, top: number, { doNotPushHistory = false }: DslOpOptions = {}) {
1083
1289
  const node = toRaw(this.get('node'));
1084
1290
  if (!node || isPage(node)) return;
1085
1291
 
1086
1292
  const newStyle = calcMoveStyle(node.style || {}, left, top);
1087
1293
  if (!newStyle) return;
1088
1294
 
1089
- await this.update({ id: node.id, type: node.type, style: newStyle });
1295
+ await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory });
1090
1296
  }
1091
1297
 
1092
1298
  public resetState() {
@@ -1134,22 +1340,20 @@ class Editor extends BaseService {
1134
1340
  }
1135
1341
 
1136
1342
  private addModifiedNodeId(id: Id) {
1137
- if (!this.isHistoryStateChange) {
1138
- this.get('modifiedNodeIds').set(id, id);
1139
- }
1343
+ this.get('modifiedNodeIds').set(id, id);
1140
1344
  }
1141
1345
 
1142
1346
  private captureSelectionBeforeOp() {
1143
- if (this.isHistoryStateChange || this.selectionBeforeOp) return;
1347
+ if (this.selectionBeforeOp) return;
1144
1348
  this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
1145
1349
  }
1146
1350
 
1147
- private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
1148
- if (this.isHistoryStateChange) {
1149
- this.selectionBeforeOp = null;
1150
- return;
1151
- }
1152
-
1351
+ private pushOpHistory(
1352
+ opType: HistoryOpType,
1353
+ extra: Partial<StepValue>,
1354
+ pageData: { name: string; id: Id },
1355
+ historyDescription?: string,
1356
+ ) {
1153
1357
  const step: StepValue = {
1154
1358
  data: pageData,
1155
1359
  opType,
@@ -1158,43 +1362,124 @@ class Editor extends BaseService {
1158
1362
  modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
1159
1363
  ...extra,
1160
1364
  };
1161
- historyService.push(step);
1365
+ if (historyDescription) step.historyDescription = historyDescription;
1366
+ // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
1367
+ // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
1368
+ historyService.push(step, pageData.id);
1162
1369
  this.selectionBeforeOp = null;
1163
- this.isHistoryStateChange = false;
1164
1370
  }
1165
1371
 
1166
1372
  /**
1167
1373
  * 应用历史操作(撤销 / 重做)
1374
+ *
1375
+ * 所有 DSL 修改都走 `editor.add / remove / update`,并通过 `doNotPushHistory` 阻止再次入栈、
1376
+ * `doNotSelect / doNotSwitchPage` 让选区由方法末尾的统一逻辑兜底。
1377
+ *
1378
+ * 注意:这些公开方法会发出 add / remove / update 事件,业务侧若需要区分"用户操作"与"撤销重做触发",
1379
+ * 请监听 `history-change` 事件配合判断。
1380
+ *
1168
1381
  * @param step 操作记录
1169
1382
  * @param reverse true = 撤销,false = 重做
1170
1383
  */
1171
1384
  private async applyHistoryOp(step: StepValue, reverse: boolean) {
1172
- this.isHistoryStateChange = true;
1173
-
1174
1385
  const root = this.get('root');
1175
1386
  const stage = this.get('stage');
1176
1387
  if (!root) return;
1177
1388
 
1178
- const ctx: HistoryOpContext = {
1179
- root,
1180
- stage,
1181
- getNodeById: (id, raw) => this.getNodeById(id, raw),
1182
- getNodeInfo: (id, raw) => this.getNodeInfo(id, raw),
1183
- setRoot: (r) => this.set('root', r),
1184
- setPage: (p) => this.set('page', p),
1185
- getPage: () => this.get('page'),
1186
- };
1389
+ const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const;
1187
1390
 
1188
1391
  switch (step.opType) {
1189
- case 'add':
1190
- await applyHistoryAddOp(step, reverse, ctx);
1392
+ case 'add': {
1393
+ const nodes = step.nodes ?? [];
1394
+ if (reverse) {
1395
+ // 撤销 add:把当时加入的节点删除
1396
+ for (const n of nodes) {
1397
+ const existing = this.getNodeById(n.id, false);
1398
+ if (existing) {
1399
+ await this.remove(existing, commonOpts);
1400
+ }
1401
+ }
1402
+ } 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
+ });
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1191
1426
  break;
1192
- case 'remove':
1193
- await applyHistoryRemoveOp(step, reverse, ctx);
1427
+ }
1428
+ case 'remove': {
1429
+ const items = step.removedItems ?? [];
1430
+ if (reverse) {
1431
+ // 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
1432
+ const sorted = [...items].sort((a, b) => a.index - b.index);
1433
+ for (const { node, parentId, index } of sorted) {
1434
+ const parent = this.getNodeById(parentId, false) as MContainer | null;
1435
+ if (parent?.items) {
1436
+ parent.items.splice(index, 0, cloneDeep(node));
1437
+ await stage?.add({
1438
+ config: cloneDeep(node),
1439
+ parent: cloneDeep(parent),
1440
+ parentId,
1441
+ root: cloneDeep(root),
1442
+ });
1443
+ }
1444
+ }
1445
+ } else {
1446
+ // 重做 remove:再删一次
1447
+ for (const { node } of items) {
1448
+ const existing = this.getNodeById(node.id, false);
1449
+ if (existing) {
1450
+ await this.remove(existing, commonOpts);
1451
+ }
1452
+ }
1453
+ }
1194
1454
  break;
1195
- case 'update':
1196
- await applyHistoryUpdateOp(step, reverse, ctx);
1455
+ }
1456
+ case 'update': {
1457
+ const items = step.updatedItems ?? [];
1458
+ // 优先按 changeRecords 局部 patch:仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
1459
+ // 没有 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);
1470
+ }
1471
+ const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
1472
+ setValueByKeyPath(record.propPath, value, patch);
1473
+ }
1474
+ return patch;
1475
+ }
1476
+ return cloneDeep(reverse ? oldNode : newNode);
1477
+ });
1478
+ if (configs.length) {
1479
+ await this.update(configs, { doNotPushHistory: true });
1480
+ }
1197
1481
  break;
1482
+ }
1198
1483
  }
1199
1484
 
1200
1485
  this.set('modifiedNodeIds', step.modifiedNodeIds);
@@ -1216,8 +1501,6 @@ class Editor extends BaseService {
1216
1501
  }, 0);
1217
1502
  this.emit('history-change', page as MPage | MPageFragment);
1218
1503
  }
1219
-
1220
- this.isHistoryStateChange = false;
1221
1504
  }
1222
1505
 
1223
1506
  private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {