@tmagic/editor 1.8.0-beta.1 → 1.8.0-beta.10

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 (232) hide show
  1. package/dist/es/Editor.vue_vue_type_script_setup_true_lang.js +31 -5
  2. package/dist/es/components/CodeBlockEditor.vue_vue_type_script_setup_true_lang.js +21 -113
  3. package/dist/es/components/CodeParams.vue_vue_type_script_setup_true_lang.js +7 -1
  4. package/dist/es/components/CompareForm.js +5 -0
  5. package/dist/es/components/CompareForm.vue_vue_type_script_setup_true_lang.js +196 -0
  6. package/dist/es/components/ContentMenu.vue_vue_type_script_setup_true_lang.js +1 -1
  7. package/dist/es/components/FloatingBox.vue_vue_type_script_setup_true_lang.js +2 -2
  8. package/dist/es/components/Icon.vue_vue_type_script_setup_true_lang.js +1 -1
  9. package/dist/es/components/Resizer.vue_vue_type_script_setup_true_lang.js +1 -1
  10. package/dist/es/components/ScrollBar.vue_vue_type_script_setup_true_lang.js +1 -1
  11. package/dist/es/components/ScrollViewer.vue_vue_type_script_setup_true_lang.js +1 -1
  12. package/dist/es/components/SearchInput.vue_vue_type_script_setup_true_lang.js +1 -1
  13. package/dist/es/components/SplitView.vue_vue_type_script_setup_true_lang.js +1 -1
  14. package/dist/es/components/ToolButton.vue_vue_type_script_setup_true_lang.js +7 -7
  15. package/dist/es/components/Tree.vue_vue_type_script_setup_true_lang.js +1 -1
  16. package/dist/es/components/TreeNode.vue_vue_type_script_setup_true_lang.js +1 -1
  17. package/dist/es/editorProps.js +2 -0
  18. package/dist/es/fields/Code.vue_vue_type_script_setup_true_lang.js +20 -4
  19. package/dist/es/fields/CodeLink.vue_vue_type_script_setup_true_lang.js +3 -6
  20. package/dist/es/fields/CodeSelect.vue_vue_type_script_setup_true_lang.js +19 -3
  21. package/dist/es/fields/CodeSelectCol.vue_vue_type_script_setup_true_lang.js +42 -8
  22. package/dist/es/fields/CondOpSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  23. package/dist/es/fields/DataSourceFieldSelect/FieldSelect.vue_vue_type_script_setup_true_lang.js +14 -6
  24. package/dist/es/fields/DataSourceFieldSelect/Index.vue_vue_type_script_setup_true_lang.js +3 -3
  25. package/dist/es/fields/DataSourceFields.vue_vue_type_script_setup_true_lang.js +41 -16
  26. package/dist/es/fields/DataSourceInput.vue_vue_type_script_setup_true_lang.js +2 -2
  27. package/dist/es/fields/DataSourceMethodSelect.vue_vue_type_script_setup_true_name_true_lang.js +9 -5
  28. package/dist/es/fields/DataSourceMethods.vue_vue_type_script_setup_true_lang.js +40 -19
  29. package/dist/es/fields/DataSourceMocks.vue_vue_type_script_setup_true_lang.js +17 -9
  30. package/dist/es/fields/DataSourceSelect.vue_vue_type_script_setup_true_lang.js +4 -2
  31. package/dist/es/fields/DisplayConds.vue_vue_type_script_setup_true_lang.js +1 -1
  32. package/dist/es/fields/EventSelect.vue_vue_type_script_setup_true_lang.js +59 -12
  33. package/dist/es/fields/KeyValue.vue_vue_type_script_setup_true_lang.js +12 -7
  34. package/dist/es/fields/PageFragmentSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  35. package/dist/es/fields/StyleSetter/Index.vue_vue_type_script_setup_true_lang.js +1 -1
  36. package/dist/es/fields/StyleSetter/components/BackgroundPosition.vue_vue_type_script_setup_true_lang.js +1 -1
  37. package/dist/es/fields/StyleSetter/components/Border.vue_vue_type_script_setup_true_lang.js +28 -6
  38. package/dist/es/fields/StyleSetter/components/Box.vue_vue_type_script_setup_true_lang.js +1 -1
  39. package/dist/es/fields/StyleSetter/icons/background-repeat/NoRepeat.js +1 -1
  40. package/dist/es/fields/StyleSetter/icons/background-repeat/Repeat.js +1 -1
  41. package/dist/es/fields/StyleSetter/icons/background-repeat/RepeatX.js +1 -1
  42. package/dist/es/fields/StyleSetter/icons/background-repeat/RepeatY.js +1 -1
  43. package/dist/es/fields/StyleSetter/icons/display/Block.js +1 -1
  44. package/dist/es/fields/StyleSetter/icons/display/Flex.js +1 -1
  45. package/dist/es/fields/StyleSetter/icons/display/Inline.js +1 -1
  46. package/dist/es/fields/StyleSetter/icons/display/InlineBlock.js +1 -1
  47. package/dist/es/fields/StyleSetter/icons/display/None.js +1 -1
  48. package/dist/es/fields/StyleSetter/icons/flex-direction/Column.js +1 -1
  49. package/dist/es/fields/StyleSetter/icons/flex-direction/ColumnReverse.js +1 -1
  50. package/dist/es/fields/StyleSetter/icons/flex-direction/Row.js +1 -1
  51. package/dist/es/fields/StyleSetter/icons/flex-direction/RowReverse.js +1 -1
  52. package/dist/es/fields/StyleSetter/icons/justify-content/Center.js +1 -1
  53. package/dist/es/fields/StyleSetter/icons/justify-content/FlexEnd.js +1 -1
  54. package/dist/es/fields/StyleSetter/icons/justify-content/FlexStart.js +1 -1
  55. package/dist/es/fields/StyleSetter/icons/justify-content/SpaceAround.js +1 -1
  56. package/dist/es/fields/StyleSetter/icons/justify-content/SpaceBetween.js +1 -1
  57. package/dist/es/fields/StyleSetter/icons/text-align/Center.js +1 -1
  58. package/dist/es/fields/StyleSetter/icons/text-align/Left.js +1 -1
  59. package/dist/es/fields/StyleSetter/icons/text-align/Right.js +1 -1
  60. package/dist/es/fields/StyleSetter/pro/Background.vue_vue_type_script_setup_true_lang.js +1 -1
  61. package/dist/es/fields/StyleSetter/pro/Border.vue_vue_type_script_setup_true_lang.js +1 -1
  62. package/dist/es/fields/StyleSetter/pro/Font.vue_vue_type_script_setup_true_lang.js +1 -1
  63. package/dist/es/fields/StyleSetter/pro/Layout.vue_vue_type_script_setup_true_lang.js +1 -1
  64. package/dist/es/fields/StyleSetter/pro/Position.vue_vue_type_script_setup_true_lang.js +1 -1
  65. package/dist/es/fields/StyleSetter/pro/Transform.vue_vue_type_script_setup_true_lang.js +1 -1
  66. package/dist/es/fields/UISelect.vue_vue_type_script_setup_true_lang.js +9 -6
  67. package/dist/es/hooks/use-code-block-edit.js +7 -4
  68. package/dist/es/hooks/use-data-source-edit.js +5 -2
  69. package/dist/es/hooks/use-stage.js +22 -10
  70. package/dist/es/icons/CenterIcon.vue_vue_type_script_setup_true_lang.js +1 -1
  71. package/dist/es/icons/CodeIcon.vue_vue_type_script_setup_true_lang.js +1 -1
  72. package/dist/es/icons/FolderMinusIcon.vue_vue_type_script_setup_true_lang.js +1 -1
  73. package/dist/es/index.js +14 -3
  74. package/dist/es/initService.js +7 -7
  75. package/dist/es/layouts/AddPageBox.vue_vue_type_script_setup_true_lang.js +1 -1
  76. package/dist/es/layouts/CodeEditor.vue_vue_type_script_setup_true_lang.js +7 -6
  77. package/dist/es/layouts/Framework.vue_vue_type_script_setup_true_lang.js +14 -9
  78. package/dist/es/layouts/NavMenu.vue_vue_type_script_setup_true_lang.js +12 -4
  79. package/dist/es/layouts/NavMenuColumn.vue_vue_type_script_setup_true_lang.js +1 -1
  80. package/dist/es/layouts/history-list/Bucket.js +5 -0
  81. package/dist/es/layouts/history-list/Bucket.vue_vue_type_script_setup_true_lang.js +71 -0
  82. package/dist/es/layouts/history-list/BucketTab.js +5 -0
  83. package/dist/es/layouts/history-list/BucketTab.vue_vue_type_script_setup_true_lang.js +62 -0
  84. package/dist/es/layouts/history-list/GroupRow.js +5 -0
  85. package/dist/es/layouts/history-list/GroupRow.vue_vue_type_script_setup_true_lang.js +269 -0
  86. package/dist/es/layouts/history-list/HistoryDiffDialog.js +5 -0
  87. package/dist/es/layouts/history-list/HistoryDiffDialog.vue_vue_type_script_setup_true_lang.js +287 -0
  88. package/dist/es/layouts/history-list/HistoryListPanel.js +5 -0
  89. package/dist/es/layouts/history-list/HistoryListPanel.vue_vue_type_script_setup_true_lang.js +327 -0
  90. package/dist/es/layouts/history-list/InitialRow.js +5 -0
  91. package/dist/es/layouts/history-list/InitialRow.vue_vue_type_script_setup_true_lang.js +83 -0
  92. package/dist/es/layouts/history-list/PageTab.js +5 -0
  93. package/dist/es/layouts/history-list/PageTab.vue_vue_type_script_setup_true_lang.js +95 -0
  94. package/dist/es/layouts/history-list/composables.js +184 -0
  95. package/dist/es/layouts/history-list/useHistoryList.js +56 -0
  96. package/dist/es/layouts/history-list/useHistoryRevert.js +306 -0
  97. package/dist/es/layouts/page-bar/AddButton.vue_vue_type_script_setup_true_lang.js +1 -1
  98. package/dist/es/layouts/page-bar/PageBar.vue_vue_type_script_setup_true_lang.js +5 -3
  99. package/dist/es/layouts/page-bar/PageBarScrollContainer.vue_vue_type_script_setup_true_lang.js +1 -1
  100. package/dist/es/layouts/page-bar/PageList.vue_vue_type_script_setup_true_lang.js +1 -1
  101. package/dist/es/layouts/page-bar/Search.vue_vue_type_script_setup_true_lang.js +2 -2
  102. package/dist/es/layouts/props-panel/FormPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  103. package/dist/es/layouts/props-panel/PropsPanel.vue_vue_type_script_setup_true_lang.js +6 -2
  104. package/dist/es/layouts/sidebar/ComponentListPanel.vue_vue_type_script_setup_true_lang.js +2 -2
  105. package/dist/es/layouts/sidebar/Sidebar.vue_vue_type_script_setup_true_lang.js +8 -4
  106. package/dist/es/layouts/sidebar/code-block/CodeBlockList.vue_vue_type_script_setup_true_lang.js +4 -4
  107. package/dist/es/layouts/sidebar/code-block/CodeBlockListPanel.vue_vue_type_script_setup_true_lang.js +2 -2
  108. package/dist/es/layouts/sidebar/code-block/useContentMenu.js +1 -1
  109. package/dist/es/layouts/sidebar/data-source/DataSourceAddButton.vue_vue_type_script_setup_true_lang.js +1 -1
  110. package/dist/es/layouts/sidebar/data-source/DataSourceConfigPanel.vue_vue_type_script_setup_true_lang.js +19 -6
  111. package/dist/es/layouts/sidebar/data-source/DataSourceList.vue_vue_type_script_setup_true_lang.js +1 -1
  112. package/dist/es/layouts/sidebar/data-source/DataSourceListPanel.vue_vue_type_script_setup_true_lang.js +25 -3
  113. package/dist/es/layouts/sidebar/data-source/useContentMenu.js +1 -1
  114. package/dist/es/layouts/sidebar/layer/LayerMenu.vue_vue_type_script_setup_true_lang.js +6 -6
  115. package/dist/es/layouts/sidebar/layer/LayerNodeTool.vue_vue_type_script_setup_true_lang.js +2 -2
  116. package/dist/es/layouts/sidebar/layer/LayerPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  117. package/dist/es/layouts/sidebar/layer/use-node-status.js +1 -1
  118. package/dist/es/layouts/workspace/Breadcrumb.vue_vue_type_script_setup_true_lang.js +1 -1
  119. package/dist/es/layouts/workspace/Workspace.vue_vue_type_script_setup_true_lang.js +1 -1
  120. package/dist/es/layouts/workspace/viewer/NodeListMenu.vue_vue_type_script_setup_true_lang.js +1 -1
  121. package/dist/es/layouts/workspace/viewer/Stage.vue_vue_type_script_setup_true_lang.js +2 -2
  122. package/dist/es/layouts/workspace/viewer/StageOverlay.vue_vue_type_script_setup_true_lang.js +1 -1
  123. package/dist/es/layouts/workspace/viewer/ViewerMenu.vue_vue_type_script_setup_true_lang.js +9 -9
  124. package/dist/es/services/BaseService.js +31 -68
  125. package/dist/es/services/codeBlock.js +286 -8
  126. package/dist/es/services/componentList.js +2 -2
  127. package/dist/es/services/dataSource.js +287 -8
  128. package/dist/es/services/dep.js +21 -9
  129. package/dist/es/services/editor.js +655 -194
  130. package/dist/es/services/events.js +2 -2
  131. package/dist/es/services/history.js +390 -47
  132. package/dist/es/services/keybinding.js +5 -5
  133. package/dist/es/services/props.js +8 -8
  134. package/dist/es/services/stageOverlay.js +2 -2
  135. package/dist/es/services/storage.js +2 -2
  136. package/dist/es/services/ui.js +5 -2
  137. package/dist/es/style.css +456 -8
  138. package/dist/es/utils/code-block.js +122 -0
  139. package/dist/es/utils/content-menu.js +19 -12
  140. package/dist/es/utils/data-source/index.js +2 -0
  141. package/dist/es/utils/dep/worker.js +1 -1
  142. package/dist/es/utils/editor.js +105 -51
  143. package/dist/es/utils/history.js +220 -0
  144. package/dist/es/utils/indexed-db.js +86 -0
  145. package/dist/es/utils/undo-redo.js +80 -1
  146. package/dist/style.css +456 -8
  147. package/dist/tmagic-editor.umd.cjs +10135 -5646
  148. package/package.json +8 -8
  149. package/src/Editor.vue +25 -1
  150. package/src/components/CodeBlockEditor.vue +20 -109
  151. package/src/components/CodeParams.vue +6 -0
  152. package/src/components/CompareForm.vue +277 -0
  153. package/src/components/ToolButton.vue +2 -2
  154. package/src/editorProps.ts +9 -0
  155. package/src/fields/Code.vue +19 -3
  156. package/src/fields/CodeLink.vue +2 -5
  157. package/src/fields/CodeSelect.vue +17 -1
  158. package/src/fields/CodeSelectCol.vue +37 -3
  159. package/src/fields/DataSourceFieldSelect/FieldSelect.vue +23 -3
  160. package/src/fields/DataSourceFieldSelect/Index.vue +2 -2
  161. package/src/fields/DataSourceFields.vue +48 -10
  162. package/src/fields/DataSourceInput.vue +3 -3
  163. package/src/fields/DataSourceMethodSelect.vue +12 -4
  164. package/src/fields/DataSourceMethods.vue +55 -24
  165. package/src/fields/DataSourceMocks.vue +13 -3
  166. package/src/fields/DataSourceSelect.vue +4 -1
  167. package/src/fields/EventSelect.vue +57 -8
  168. package/src/fields/KeyValue.vue +17 -4
  169. package/src/fields/StyleSetter/components/Border.vue +15 -6
  170. package/src/fields/UISelect.vue +5 -2
  171. package/src/hooks/use-code-block-edit.ts +9 -5
  172. package/src/hooks/use-data-source-edit.ts +2 -2
  173. package/src/hooks/use-stage.ts +26 -9
  174. package/src/index.ts +8 -0
  175. package/src/initService.ts +1 -1
  176. package/src/layouts/CodeEditor.vue +17 -5
  177. package/src/layouts/Framework.vue +11 -6
  178. package/src/layouts/NavMenu.vue +12 -3
  179. package/src/layouts/history-list/Bucket.vue +91 -0
  180. package/src/layouts/history-list/BucketTab.vue +72 -0
  181. package/src/layouts/history-list/GroupRow.vue +304 -0
  182. package/src/layouts/history-list/HistoryDiffDialog.vue +273 -0
  183. package/src/layouts/history-list/HistoryListPanel.vue +367 -0
  184. package/src/layouts/history-list/InitialRow.vue +71 -0
  185. package/src/layouts/history-list/PageTab.vue +118 -0
  186. package/src/layouts/history-list/composables.ts +309 -0
  187. package/src/layouts/history-list/useHistoryList.ts +60 -0
  188. package/src/layouts/history-list/useHistoryRevert.ts +405 -0
  189. package/src/layouts/page-bar/PageBar.vue +4 -2
  190. package/src/layouts/props-panel/PropsPanel.vue +5 -1
  191. package/src/layouts/sidebar/ComponentListPanel.vue +9 -5
  192. package/src/layouts/sidebar/Sidebar.vue +8 -3
  193. package/src/layouts/sidebar/code-block/CodeBlockList.vue +5 -5
  194. package/src/layouts/sidebar/code-block/CodeBlockListPanel.vue +1 -1
  195. package/src/layouts/sidebar/code-block/useContentMenu.ts +1 -1
  196. package/src/layouts/sidebar/data-source/DataSourceConfigPanel.vue +31 -2
  197. package/src/layouts/sidebar/data-source/DataSourceListPanel.vue +27 -2
  198. package/src/layouts/sidebar/data-source/useContentMenu.ts +1 -1
  199. package/src/layouts/sidebar/layer/LayerMenu.vue +19 -11
  200. package/src/layouts/sidebar/layer/LayerNodeTool.vue +7 -4
  201. package/src/layouts/sidebar/layer/use-node-status.ts +9 -6
  202. package/src/layouts/workspace/viewer/Stage.vue +1 -1
  203. package/src/layouts/workspace/viewer/ViewerMenu.vue +8 -8
  204. package/src/services/BaseService.ts +35 -86
  205. package/src/services/codeBlock.ts +361 -5
  206. package/src/services/dataSource.ts +336 -6
  207. package/src/services/dep.ts +32 -9
  208. package/src/services/editor.ts +834 -194
  209. package/src/services/history.ts +509 -49
  210. package/src/services/keybinding.ts +3 -3
  211. package/src/services/props.ts +2 -2
  212. package/src/services/ui.ts +7 -0
  213. package/src/theme/history-list-panel.scss +557 -0
  214. package/src/theme/page-bar.scss +0 -4
  215. package/src/theme/props-panel.scss +3 -3
  216. package/src/theme/style-setter/border.scss +4 -1
  217. package/src/theme/theme.scss +1 -0
  218. package/src/type.ts +594 -31
  219. package/src/utils/code-block.ts +150 -0
  220. package/src/utils/content-menu.ts +25 -13
  221. package/src/utils/data-source/index.ts +2 -0
  222. package/src/utils/dep/worker.ts +5 -3
  223. package/src/utils/editor.ts +166 -60
  224. package/src/utils/history.ts +286 -0
  225. package/src/utils/index.ts +3 -0
  226. package/src/utils/indexed-db.ts +122 -0
  227. package/src/utils/undo-redo.ts +111 -0
  228. package/types/index.d.ts +3171 -889
  229. package/dist/es/utils/compose.js +0 -44
  230. package/dist/es/utils/editor-history.js +0 -100
  231. package/src/utils/compose.ts +0 -52
  232. package/src/utils/editor-history.ts +0 -138
@@ -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, isPage, isPageFragment } 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,10 +42,13 @@ import type {
34
42
  AsyncHookPlugin,
35
43
  AsyncMethodName,
36
44
  DslOpOptions,
45
+ DslOpWithHistoryIdsResult,
37
46
  EditorEvents,
38
47
  EditorNodeInfo,
48
+ HistoryOpSource,
39
49
  HistoryOpType,
40
50
  PastePosition,
51
+ StepDiffItem,
41
52
  StepValue,
42
53
  StoreState,
43
54
  StoreStateKey,
@@ -50,6 +61,7 @@ import {
50
61
  classifyDragSources,
51
62
  collectRelatedNodes,
52
63
  COPY_STORAGE_KEY,
64
+ describeStepForRevert,
53
65
  editorNodeMergeCustomizer,
54
66
  fixNodePosition,
55
67
  getInitPositionStyle,
@@ -62,28 +74,24 @@ import {
62
74
  setLayout,
63
75
  toggleFixedPosition,
64
76
  } from '@editor/utils/editor';
65
- import type { HistoryOpContext } from '@editor/utils/editor-history';
66
- import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
77
+ import { getLastPushedHistoryIds } from '@editor/utils/history';
67
78
  import { beforePaste, getAddParent } from '@editor/utils/operator';
68
79
 
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
- };
80
+ type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
78
81
 
79
82
  /**
80
- * 经过 BaseService 的插件 / 中间件包装后,源方法的形参可能被注入为 dispatch 函数
81
- * parent 形参位置被注入为函数(或为空值)时,归一为 null,由调用方继续走默认 parent 逻辑
83
+ * 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
84
+ * `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
85
+ * 缺省(未传 / 空数组)才退化为整节点替换。
82
86
  */
83
- const safeParent = (parent: unknown): MContainer | null => {
84
- if (!parent || typeof parent === 'function') return null;
85
- return parent as MContainer;
86
- };
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
+ }));
87
95
 
88
96
  class Editor extends BaseService {
89
97
  public state: StoreState = reactive({
@@ -101,8 +109,13 @@ class Editor extends BaseService {
101
109
  disabledMultiSelect: false,
102
110
  alwaysMultiSelect: false,
103
111
  });
104
- private isHistoryStateChange = false;
105
112
  private selectionBeforeOp: Id[] | null = null;
113
+ /**
114
+ * 最近一次 pushOpHistory 写入的历史记录 uuid。
115
+ * 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id;
116
+ * 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
117
+ */
118
+ private lastPushedHistoryId: string | null = null;
106
119
 
107
120
  constructor() {
108
121
  super(
@@ -116,8 +129,13 @@ class Editor extends BaseService {
116
129
  * 设置当前指点节点配置
117
130
  * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
118
131
  * @param value MNode
132
+ * @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效)
119
133
  */
120
- 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
+ ) {
121
139
  const preValue = this.state[name];
122
140
  this.state[name] = value;
123
141
 
@@ -136,13 +154,32 @@ class Editor extends BaseService {
136
154
  this.state.pageLength = getPageList(app).length || 0;
137
155
  this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
138
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.getMarker('page', pageNode.id)) {
169
+ historyService.setMarker('page', pageNode.id, {
170
+ name: pageNode.name,
171
+ source: options.historySource,
172
+ });
173
+ }
174
+ });
175
+ }
139
176
  } else {
140
177
  this.state.pageLength = 0;
141
178
  this.state.pageFragmentLength = 0;
142
179
  this.state.stageLoading = false;
143
180
  }
144
181
 
145
- 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);
146
183
  }
147
184
  }
148
185
 
@@ -167,7 +204,30 @@ class Editor extends BaseService {
167
204
  root = toRaw(root);
168
205
  }
169
206
 
170
- 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);
171
231
  }
172
232
 
173
233
  /**
@@ -212,7 +272,7 @@ class Editor extends BaseService {
212
272
  * 只有容器拥有布局
213
273
  */
214
274
  public async getLayout(parent: MNode, node?: MNode | null): Promise<Layout> {
215
- if (node && typeof node !== 'function' && isFixed(node.style || {})) return Layout.FIXED;
275
+ if (node && isFixed(node.style || {})) return Layout.FIXED;
216
276
 
217
277
  if (parent.layout) {
218
278
  return parent.layout;
@@ -237,12 +297,6 @@ class Editor extends BaseService {
237
297
  this.set('page', page);
238
298
  this.set('parent', parent);
239
299
 
240
- if (page) {
241
- historyService.changePage(toRaw(page));
242
- } else {
243
- historyService.resetState();
244
- }
245
-
246
300
  if (node?.id) {
247
301
  this.get('stage')
248
302
  ?.renderer?.runtime?.getApp?.()
@@ -388,16 +442,20 @@ class Editor extends BaseService {
388
442
  * @param options 可选配置
389
443
  * @param options.doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
390
444
  * @param options.doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
445
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
391
446
  * @returns 添加后的节点
392
447
  */
393
448
  public async add(
394
449
  addNode: AddMNode | MNode[],
395
450
  parent?: MContainer | null,
396
- options?: DslOpOptions,
451
+ {
452
+ doNotSelect = false,
453
+ doNotSwitchPage = false,
454
+ doNotPushHistory = false,
455
+ historyDescription,
456
+ historySource,
457
+ }: DslOpOptions = {},
397
458
  ): Promise<MNode | MNode[]> {
398
- const safeParentNode = safeParent(parent);
399
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
400
-
401
459
  this.captureSelectionBeforeOp();
402
460
 
403
461
  const stage = this.get('stage');
@@ -420,7 +478,7 @@ class Editor extends BaseService {
420
478
  if ((isPage(node) || isPageFragment(node)) && root) {
421
479
  return this.doAdd(node, root);
422
480
  }
423
- const parentNode = safeParentNode ?? getAddParent(node);
481
+ const parentNode = parent ?? getAddParent(node);
424
482
  if (!parentNode) throw new Error('未找到父元素');
425
483
  return this.doAdd(node, parentNode);
426
484
  }),
@@ -455,30 +513,40 @@ class Editor extends BaseService {
455
513
 
456
514
  if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
457
515
  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
- );
516
+ if (!doNotPushHistory) {
517
+ const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
518
+ this.pushOpHistory('add', {
519
+ diff: newNodes.map((n) => {
520
+ const p = this.getParentById(n.id, false) as MContainer;
521
+ const idx = p ? getNodeIndex(n.id, p) : -1;
522
+ return {
523
+ newSchema: cloneDeep(toRaw(n)),
524
+ parentId,
525
+ index: typeof idx === 'number' ? idx : -1,
526
+ };
527
+ }),
528
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
529
+ historyDescription,
530
+ source: historySource,
531
+ });
532
+ } else {
533
+ this.selectionBeforeOp = null;
534
+ }
472
535
  }
473
536
 
474
537
  this.emit('add', newNodes);
538
+ this.emit('change', {
539
+ type: 'add',
540
+ data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
541
+ });
475
542
 
476
543
  return Array.isArray(addNode) ? newNodes : newNodes[0];
477
544
  }
478
545
 
479
- public async doRemove(node: MNode, options?: DslOpOptions): Promise<void> {
480
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
481
-
546
+ public async doRemove(
547
+ node: MNode,
548
+ { doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
549
+ ): Promise<void> {
482
550
  const root = this.get('root');
483
551
  if (!root) throw new Error('root不能为空');
484
552
 
@@ -516,8 +584,6 @@ class Editor extends BaseService {
516
584
  stage?.select(pages[0].id);
517
585
  } else {
518
586
  this.selectRoot();
519
-
520
- historyService.resetPage();
521
587
  }
522
588
  };
523
589
 
@@ -557,15 +623,26 @@ class Editor extends BaseService {
557
623
  * @param options 可选配置
558
624
  * @param options.doNotSelect 删除后是否不更新当前选中节点(默认 false,删除后会选中父节点或首个页面)
559
625
  * @param options.doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
626
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
560
627
  */
561
- public async remove(nodeOrNodeList: MNode | MNode[], options?: DslOpOptions): Promise<void> {
562
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
563
-
628
+ public async remove(
629
+ nodeOrNodeList: MNode | MNode[],
630
+ {
631
+ doNotSelect = false,
632
+ doNotSwitchPage = false,
633
+ doNotPushHistory = false,
634
+ historyDescription,
635
+ historySource,
636
+ }: DslOpOptions = {},
637
+ ): Promise<void> {
564
638
  this.captureSelectionBeforeOp();
565
639
 
566
640
  const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
567
641
 
568
- const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
642
+ // 删除后节点已从树中移除,无法再反查所属 page,这里在删除前先逐个捕获用于 `change` 事件
643
+ const changeItems = nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) }));
644
+
645
+ const removedItems: StepDiffItem<MNode>[] = [];
569
646
  let pageForOp: { name: string; id: Id } | null = null;
570
647
  if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
571
648
  for (const n of nodes) {
@@ -576,7 +653,7 @@ class Editor extends BaseService {
576
653
  }
577
654
  const idx = getNodeIndex(curNode.id, parent);
578
655
  removedItems.push({
579
- node: cloneDeep(toRaw(curNode)),
656
+ oldSchema: cloneDeep(toRaw(curNode)),
580
657
  parentId: parent.id,
581
658
  index: typeof idx === 'number' ? idx : -1,
582
659
  });
@@ -587,15 +664,25 @@ class Editor extends BaseService {
587
664
  await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
588
665
 
589
666
  if (removedItems.length > 0 && pageForOp) {
590
- this.pushOpHistory('remove', { removedItems }, pageForOp);
667
+ if (!doNotPushHistory) {
668
+ this.pushOpHistory('remove', {
669
+ diff: removedItems,
670
+ pageData: pageForOp,
671
+ historyDescription,
672
+ source: historySource,
673
+ });
674
+ } else {
675
+ this.selectionBeforeOp = null;
676
+ }
591
677
  }
592
678
 
593
679
  this.emit('remove', nodes);
680
+ this.emit('change', { type: 'remove', data: changeItems });
594
681
  }
595
682
 
596
683
  public async doUpdate(
597
684
  config: MNode,
598
- { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
685
+ { changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
599
686
  ): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
600
687
  const root = this.get('root');
601
688
  if (!root) throw new Error('root为空');
@@ -608,14 +695,14 @@ class Editor extends BaseService {
608
695
 
609
696
  const node = toRaw(info.node);
610
697
 
611
- let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
698
+ let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
612
699
 
613
700
  newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
614
701
 
615
702
  if (!newConfig.type) throw new Error('配置缺少type值');
616
703
 
617
704
  if (newConfig.type === NodeType.ROOT) {
618
- this.set('root', newConfig as MApp);
705
+ this.set('root', newConfig as MApp, { historySource });
619
706
  return {
620
707
  oldNode: node,
621
708
  newNode: newConfig,
@@ -668,37 +755,68 @@ class Editor extends BaseService {
668
755
  * 更新节点
669
756
  * update后会触发依赖收集,收集完后会掉stage.update方法
670
757
  * @param config 新的节点配置,配置中需要有id信息
758
+ * @param data 额外数据
759
+ * @param data.changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 changeRecordList)
760
+ * @param data.changeRecordList 多节点 form 端变更记录列表,按 config 数组同序对应每个节点;优先级高于 changeRecords
761
+ * @param data.doNotPushHistory 是否不写入历史记录(默认 false)
762
+ * @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示(不影响 undo/redo 行为)
671
763
  * @returns 更新后的节点配置
672
764
  */
673
765
  public async update(
674
766
  config: MNode | MNode[],
675
- data: { changeRecords?: ChangeRecord[] } = {},
767
+ data: {
768
+ changeRecords?: ChangeRecord[];
769
+ changeRecordList?: ChangeRecord[][];
770
+ doNotPushHistory?: boolean;
771
+ historyDescription?: string;
772
+ historySource?: HistoryOpSource;
773
+ } = {},
676
774
  ): Promise<MNode | MNode[]> {
677
775
  this.captureSelectionBeforeOp();
678
776
 
777
+ const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription, historySource } = data;
778
+
679
779
  const nodes = Array.isArray(config) ? config : [config];
680
780
 
681
- const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
781
+ // 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
782
+ // 否则同一份 changeRecords 会被复用到每个节点上,nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
783
+ const updateData = await Promise.all(
784
+ nodes.map((node, index) => {
785
+ const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
786
+ return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
787
+ }),
788
+ );
682
789
 
683
790
  if (updateData[0].oldNode?.type !== NodeType.ROOT) {
684
791
  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
- );
792
+ if (curNodes.length) {
793
+ if (!doNotPushHistory) {
794
+ const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
795
+ this.pushOpHistory('update', {
796
+ // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
797
+ // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
798
+ diff: buildUpdateDiff(
799
+ updateData.map((d) => ({
800
+ oldNode: cloneDeep(d.oldNode),
801
+ newNode: cloneDeep(d.newNode),
802
+ changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
803
+ })),
804
+ ),
805
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
806
+ historyDescription,
807
+ source: historySource,
808
+ });
809
+ } else {
810
+ this.selectionBeforeOp = null;
811
+ }
697
812
  }
698
- this.isHistoryStateChange = false;
699
813
  }
700
814
 
701
815
  this.emit('update', updateData);
816
+ this.emit('change', {
817
+ type: 'update',
818
+ data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
819
+ });
702
820
  return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
703
821
  }
704
822
 
@@ -709,11 +827,14 @@ class Editor extends BaseService {
709
827
  * @param options 可选配置
710
828
  * @param options.doNotSelect 排序后是否不更新当前选中节点(默认 false)
711
829
  * @param options.doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
830
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
712
831
  * @returns void
713
832
  */
714
- public async sort(id1: Id, id2: Id, options?: DslOpOptions): Promise<void> {
715
- const { doNotSelect = false } = safeOptions<DslOpOptions>(options);
716
-
833
+ public async sort(
834
+ id1: Id,
835
+ id2: Id,
836
+ { doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {},
837
+ ): Promise<void> {
717
838
  this.captureSelectionBeforeOp();
718
839
 
719
840
  const root = this.get('root');
@@ -732,7 +853,7 @@ class Editor extends BaseService {
732
853
 
733
854
  parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
734
855
 
735
- await this.update(parent);
856
+ await this.update(parent, { doNotPushHistory, historySource });
736
857
  if (!doNotSelect) {
737
858
  await this.select(node);
738
859
  }
@@ -779,15 +900,20 @@ class Editor extends BaseService {
779
900
  * @param options 可选配置
780
901
  * @param options.doNotSelect 粘贴后是否不更新当前选中节点(默认 false)
781
902
  * @param options.doNotSwitchPage 粘贴后是否不切换当前页面(默认 false;跨页粘贴时为 true 会跳过页面切换)
903
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
782
904
  * @returns 添加后的组件节点配置
783
905
  */
784
906
  public async paste(
785
907
  position: PastePosition = {},
786
908
  collectorOptions?: TargetOptions,
787
- options?: DslOpOptions,
909
+ {
910
+ doNotSelect = false,
911
+ doNotSwitchPage = false,
912
+ doNotPushHistory = false,
913
+ historyDescription,
914
+ historySource,
915
+ }: DslOpOptions = {},
788
916
  ): Promise<MNode | MNode[] | void> {
789
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
790
-
791
917
  const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
792
918
  if (!Array.isArray(config)) return;
793
919
 
@@ -807,7 +933,13 @@ class Editor extends BaseService {
807
933
  propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
808
934
  }
809
935
 
810
- return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage });
936
+ return this.add(pasteConfigs, parent, {
937
+ doNotSelect,
938
+ doNotSwitchPage,
939
+ doNotPushHistory,
940
+ historyDescription,
941
+ historySource,
942
+ });
811
943
  }
812
944
 
813
945
  public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@@ -838,17 +970,19 @@ class Editor extends BaseService {
838
970
  * @param options 可选配置
839
971
  * @param options.doNotSelect 居中后是否不更新当前选中节点(默认 false)
840
972
  * @param options.doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style,方法内为空操作;保留以与其它 DSL 操作 API 一致)
973
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
841
974
  * @returns 当前组件节点配置
842
975
  */
843
- public async alignCenter(config: MNode | MNode[], options?: DslOpOptions): Promise<MNode | MNode[]> {
844
- const { doNotSelect = false } = safeOptions<DslOpOptions>(options);
845
-
976
+ public async alignCenter(
977
+ config: MNode | MNode[],
978
+ { doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
979
+ ): Promise<MNode | MNode[]> {
846
980
  const nodes = Array.isArray(config) ? config : [config];
847
981
  const stage = this.get('stage');
848
982
 
849
983
  const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
850
984
 
851
- const newNode = await this.update(newNodes);
985
+ const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource });
852
986
 
853
987
  if (!doNotSelect) {
854
988
  if (newNodes.length > 1) {
@@ -864,8 +998,13 @@ class Editor extends BaseService {
864
998
  /**
865
999
  * 移动当前选中节点位置
866
1000
  * @param offset 偏移量
1001
+ * @param options 可选配置
1002
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
867
1003
  */
868
- public async moveLayer(offset: number | LayerOffset): Promise<void> {
1004
+ public async moveLayer(
1005
+ offset: number | LayerOffset,
1006
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1007
+ ): Promise<void> {
869
1008
  this.captureSelectionBeforeOp();
870
1009
 
871
1010
  const root = this.get('root');
@@ -902,94 +1041,135 @@ class Editor extends BaseService {
902
1041
  });
903
1042
 
904
1043
  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
- );
1044
+ if (!doNotPushHistory) {
1045
+ const pageForOp = this.getNodeInfo(node.id, false).page;
1046
+ this.pushOpHistory(
1047
+ 'update',
1048
+
1049
+ {
1050
+ diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
1051
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1052
+ historyDescription,
1053
+ source: historySource,
1054
+ },
1055
+ );
1056
+ } else {
1057
+ this.selectionBeforeOp = null;
1058
+ }
913
1059
 
914
1060
  this.emit('move-layer', offset);
1061
+ this.emit('change', {
1062
+ type: 'move-layer',
1063
+ data: [{ node, page: this.getPageOfNode(node.id) }],
1064
+ offset,
1065
+ });
915
1066
  }
916
1067
 
917
1068
  /**
918
- * 移动到指定容器中
919
- * @param config 需要移动的节点
1069
+ * 移动一个或多个节点到指定容器中。
1070
+ *
1071
+ * 多选场景(config 是数组)只会产生一条历史记录,
1072
+ * `updatedItems` 涵盖所有源父容器 + 目标容器的前后快照。
1073
+ * 这避免了"多选移动到某容器"在历史栈里被切成 N 条记录。
1074
+ *
1075
+ * @param config 需要移动的节点(或节点数组,各项需带 id;style 等字段会与原节点合并)
920
1076
  * @param targetId 容器ID
921
1077
  * @param options 可选配置
922
1078
  * @param options.doNotSelect 移动后是否不更新当前选中节点(默认 false)
923
1079
  * @param options.doNotSwitchPage 移动后是否不切换当前页面(默认 false;目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
1080
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
924
1081
  */
925
- public async moveToContainer(config: MNode, targetId: Id, options?: DslOpOptions): Promise<MNode | undefined> {
926
- const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
1082
+ public async moveToContainer(
1083
+ config: MNode | MNode[],
1084
+ targetId: Id,
1085
+ {
1086
+ doNotSelect = false,
1087
+ doNotSwitchPage = false,
1088
+ doNotPushHistory = false,
1089
+ historyDescription,
1090
+ historySource,
1091
+ }: DslOpOptions = {},
1092
+ ): Promise<MNode | MNode[]> {
1093
+ const isBatch = Array.isArray(config);
1094
+ const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
1095
+
1096
+ if (configs.length === 0) {
1097
+ throw new Error('没有可移动的节点');
1098
+ }
927
1099
 
928
1100
  this.captureSelectionBeforeOp();
929
1101
 
930
- const root = this.get('root');
931
- const { node, parent, page: pageForOp } = this.getNodeInfo(config.id, false);
932
1102
  const target = this.getNodeById(targetId, false) as MContainer;
933
1103
 
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) });
1104
+ if (!target) {
1105
+ throw new Error('目标容器不存在');
1106
+ }
943
1107
 
944
- const layout = await this.getLayout(target);
1108
+ const root = this.get('root');
1109
+ const stage = this.get('stage');
945
1110
 
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);
1111
+ if (!root || !stage) {
1112
+ throw new Error('root 或 stage为空');
1113
+ }
952
1114
 
953
- target.items.push(newConfig);
1115
+ // 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
1116
+ const moves: MoveItem[] = [];
1117
+ for (const { id } of configs) {
1118
+ const { node, parent, page } = this.getNodeInfo(id, false);
1119
+ if (!node || !parent) continue;
1120
+ moves.push({ node, parent, pageForOp: page ? { name: page.name || '', id: page.id } : null });
1121
+ }
954
1122
 
955
- // 目标容器是否在非当前页面:选中目标会触发当前页面切换
956
- const targetWouldSwitchPage = this.isOnDifferentPage(target);
957
- const skipSelect = doNotSelect || (doNotSwitchPage && targetWouldSwitchPage);
1123
+ if (moves.length === 0) {
1124
+ throw new Error('没有可移动的节点');
1125
+ }
958
1126
 
959
- if (!skipSelect) {
960
- await stage.select(targetId);
1127
+ // 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
1128
+ const beforeSnapshots = new Map<Id, MNode>();
1129
+ beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
1130
+ for (const { parent } of moves) {
1131
+ if (!beforeSnapshots.has(parent.id)) {
1132
+ beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
961
1133
  }
1134
+ }
962
1135
 
963
- const targetParent = this.getParentById(target.id);
964
- await stage.update({
965
- config: cloneDeep(target),
966
- parentId: targetParent?.id,
967
- root: cloneDeep(root),
1136
+ let newConfigs: MNode[] = [];
1137
+
1138
+ const moveNodes = moves.map(({ node }) => node);
1139
+ await this.remove(moveNodes, { doNotPushHistory: true, doNotSelect, doNotSwitchPage: true });
1140
+
1141
+ newConfigs = (await this.add(moveNodes, target, {
1142
+ doNotPushHistory: true,
1143
+ doNotSelect,
1144
+ doNotSwitchPage,
1145
+ })) as MNode[];
1146
+
1147
+ if (!doNotPushHistory) {
1148
+ // 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
1149
+ const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
1150
+ oldNode,
1151
+ newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
1152
+ }));
1153
+ const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
1154
+ this.pushOpHistory('update', {
1155
+ diff: buildUpdateDiff(updatedItems),
1156
+ pageData: historyPage,
1157
+ historyDescription,
1158
+ source: historySource,
968
1159
  });
969
-
970
- if (!skipSelect) {
971
- await this.select(newConfig);
972
- stage.select(newConfig.id);
973
- }
974
-
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;
1160
+ } else {
1161
+ this.selectionBeforeOp = null;
989
1162
  }
1163
+
1164
+ return isBatch ? newConfigs : newConfigs[0];
990
1165
  }
991
1166
 
992
- public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
1167
+ public async dragTo(
1168
+ config: MNode | MNode[],
1169
+ targetParent: MContainer,
1170
+ targetIndex: number,
1171
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1172
+ ) {
993
1173
  this.captureSelectionBeforeOp();
994
1174
 
995
1175
  if (!targetParent || !Array.isArray(targetParent.items)) return;
@@ -1049,18 +1229,115 @@ class Editor extends BaseService {
1049
1229
  updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
1050
1230
  }
1051
1231
  }
1052
- const pageForOp = this.getNodeInfo(configs[0].id, false).page;
1053
- this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
1232
+ if (!doNotPushHistory) {
1233
+ const pageForOp = this.getNodeInfo(configs[0].id, false).page;
1234
+ this.pushOpHistory('update', {
1235
+ diff: buildUpdateDiff(updatedItems),
1236
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1237
+ historyDescription,
1238
+ source: historySource,
1239
+ });
1240
+ } else {
1241
+ this.selectionBeforeOp = null;
1242
+ }
1054
1243
 
1055
1244
  this.emit('drag-to', { targetIndex, configs, targetParent });
1245
+ this.emit('change', {
1246
+ type: 'drag-to',
1247
+ data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
1248
+ targetIndex,
1249
+ targetParent,
1250
+ });
1251
+ }
1252
+
1253
+ // #region AndGetHistoryId
1254
+ /**
1255
+ * 下列 *AndGetHistoryId 方法与对应的普通操作(add / remove / update ...)行为完全一致,
1256
+ * 返回值在 {@link DslOpWithHistoryIdsResult} 中同时包含原操作结果与本次写入历史栈的 uuid 列表({@link StepValue.uuid}),
1257
+ * 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
1258
+ *
1259
+ * 当本次操作未写入历史(doNotPushHistory 为 true、或操作无实际变更 / 提前返回)时 historyIds 为 `[]`。
1260
+ */
1261
+
1262
+ /** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1263
+ public async addAndGetHistoryId(
1264
+ addNode: AddMNode | MNode[],
1265
+ parent?: MContainer | null,
1266
+ options: DslOpOptions = {},
1267
+ ): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
1268
+ this.lastPushedHistoryId = null;
1269
+ const result = await this.add(addNode, parent, options);
1270
+ return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1271
+ }
1272
+
1273
+ /** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1274
+ public async removeAndGetHistoryId(
1275
+ nodeOrNodeList: MNode | MNode[],
1276
+ options: DslOpOptions = {},
1277
+ ): Promise<DslOpWithHistoryIdsResult<void>> {
1278
+ this.lastPushedHistoryId = null;
1279
+ await this.remove(nodeOrNodeList, options);
1280
+ return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1281
+ }
1282
+
1283
+ /** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1284
+ public async updateAndGetHistoryId(
1285
+ config: MNode | MNode[],
1286
+ data: {
1287
+ changeRecords?: ChangeRecord[];
1288
+ changeRecordList?: ChangeRecord[][];
1289
+ doNotPushHistory?: boolean;
1290
+ historyDescription?: string;
1291
+ historySource?: HistoryOpSource;
1292
+ } = {},
1293
+ ): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
1294
+ this.lastPushedHistoryId = null;
1295
+ const result = await this.update(config, data);
1296
+ return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1297
+ }
1298
+
1299
+ /** 等价于 {@link moveLayer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1300
+ public async moveLayerAndGetHistoryId(
1301
+ offset: number | LayerOffset,
1302
+ options: DslOpOptions = {},
1303
+ ): Promise<DslOpWithHistoryIdsResult<void>> {
1304
+ this.lastPushedHistoryId = null;
1305
+ await this.moveLayer(offset, options);
1306
+ return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1056
1307
  }
1057
1308
 
1309
+ /** 等价于 {@link moveToContainer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1310
+ public async moveToContainerAndGetHistoryId(
1311
+ config: MNode | MNode[],
1312
+ targetId: Id,
1313
+ options: DslOpOptions = {},
1314
+ ): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
1315
+ this.lastPushedHistoryId = null;
1316
+ const result = await this.moveToContainer(config, targetId, options);
1317
+ return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1318
+ }
1319
+
1320
+ /** 等价于 {@link dragTo},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1321
+ public async dragToAndGetHistoryId(
1322
+ config: MNode | MNode[],
1323
+ targetParent: MContainer,
1324
+ targetIndex: number,
1325
+ options: DslOpOptions = {},
1326
+ ): Promise<DslOpWithHistoryIdsResult<void>> {
1327
+ this.lastPushedHistoryId = null;
1328
+ await this.dragTo(config, targetParent, targetIndex, options);
1329
+ return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1330
+ }
1331
+ // #endregion AndGetHistoryId
1332
+
1058
1333
  /**
1059
1334
  * 撤销当前操作
1060
1335
  * @returns 被撤销的操作
1061
1336
  */
1062
1337
  public async undo(): Promise<StepValue | null> {
1063
- const value = historyService.undo();
1338
+ const pageId = this.get('page')?.id;
1339
+ if (pageId === undefined) return null;
1340
+ const value = historyService.undo('page', pageId);
1064
1341
  if (value) {
1065
1342
  await this.applyHistoryOp(value, true);
1066
1343
  }
@@ -1072,21 +1349,188 @@ class Editor extends BaseService {
1072
1349
  * @returns 被恢复的操作
1073
1350
  */
1074
1351
  public async redo(): Promise<StepValue | null> {
1075
- const value = historyService.redo();
1352
+ const pageId = this.get('page')?.id;
1353
+ if (pageId === undefined) return null;
1354
+ const value = historyService.redo('page', pageId);
1076
1355
  if (value) {
1077
1356
  await this.applyHistoryOp(value, false);
1078
1357
  }
1079
1358
  return value;
1080
1359
  }
1081
1360
 
1082
- public async move(left: number, top: number) {
1361
+ /**
1362
+ * 「回滚」指定页面历史步骤(类 git revert 语义):
1363
+ * - 不动原始历史栈结构(不移动 cursor、不丢弃任何步骤);
1364
+ * - 取出 `index` 对应的 step,**反向应用**一次(add→remove / remove→add / update→旧值);
1365
+ * - 把这次反向应用作为一条**新步骤**追加到栈顶,可被普通 undo / redo。
1366
+ *
1367
+ * 与 `gotoPageStep`(类 git reset)的区别在于此操作**不丢弃**目标之后的历史。
1368
+ * 与 `applyHistoryOp(reverse=true)` 的区别在于:本方法**不带** `doNotPushHistory`,
1369
+ * 反向应用会以一条新 step 入栈;并且不实施 step 中保存的选区与 modifiedNodeIds 状态,
1370
+ * 选区由用户当前位置决定,符合"新提交"语义。
1371
+ *
1372
+ * 仅对处于「已应用」状态的步骤生效——未应用的步骤本身就不存在于当前 DSL 中,反向无意义。
1373
+ *
1374
+ * @param index 目标 step 在所属页面栈中的索引(0 为最早),通常由历史面板传入
1375
+ * @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
1376
+ */
1377
+ public async revertPageStep(index: number): Promise<StepValue | null> {
1378
+ const list = historyService.getStepList('page', this.get('page')?.id);
1379
+ const entry = list[index];
1380
+ if (!entry?.applied) return null;
1381
+
1382
+ const { step } = entry;
1383
+ // 初始基线(index 0 的 initial step)是栈底线,不可回滚。
1384
+ if (step.opType === 'initial') return null;
1385
+ const root = this.get('root');
1386
+ if (!root) return null;
1387
+
1388
+ // 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
1389
+ if (step.opType === 'update') {
1390
+ const items = step.diff ?? [];
1391
+ if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
1392
+ }
1393
+
1394
+ // 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
1395
+ let revertedStep: StepValue | null = null;
1396
+ // page 的 `change` 事件回调签名为 `(pageId, step)`,这里只关心被回滚产生的新 step。
1397
+ const captureRevert = (_pageId: Id, s: StepValue) => {
1398
+ revertedStep = s;
1399
+ };
1400
+ historyService.once('change', captureRevert);
1401
+
1402
+ const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
1403
+ // revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
1404
+ const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
1405
+
1406
+ try {
1407
+ switch (step.opType) {
1408
+ case 'add': {
1409
+ // 原本是新增 → revert 即删除当时被加入的节点
1410
+ for (const { newSchema } of step.diff ?? []) {
1411
+ if (!newSchema) continue;
1412
+ const existing = this.getNodeById(newSchema.id, false);
1413
+ if (existing) {
1414
+ await this.remove(existing, opts);
1415
+ }
1416
+ }
1417
+ break;
1418
+ }
1419
+ case 'remove': {
1420
+ // 原本是删除 → revert 即把节点按原父容器加回来。
1421
+ // 按原 index 升序逐个插回,先小后大避免索引漂移。
1422
+ const items = step.diff ?? [];
1423
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1424
+ for (const { oldSchema, parentId } of sorted) {
1425
+ if (!oldSchema || parentId === undefined) continue;
1426
+ const parent = this.getNodeById(parentId, false) as MContainer | null;
1427
+ if (parent) {
1428
+ await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
1429
+ }
1430
+ }
1431
+ break;
1432
+ }
1433
+ case 'update': {
1434
+ // 原本是更新 → revert 即把 oldSchema 的值写回;
1435
+ // 优先按 changeRecords 局部 patch(仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
1436
+ const items = step.diff ?? [];
1437
+ const configs = items
1438
+ .filter((item) => item.oldSchema && item.newSchema)
1439
+ .map(({ oldSchema, newSchema, changeRecords }) => {
1440
+ const oldNode = oldSchema!;
1441
+ const newNode = newSchema!;
1442
+ if (changeRecords?.length) {
1443
+ const patch: MNode = { id: newNode.id, type: newNode.type };
1444
+ for (const record of changeRecords) {
1445
+ if (!record.propPath) {
1446
+ // 没有 propPath 视为整节点替换
1447
+ return cloneDeep(oldNode);
1448
+ }
1449
+ const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
1450
+ setValueByKeyPath(record.propPath, value, patch);
1451
+ }
1452
+ return patch;
1453
+ }
1454
+ return cloneDeep(oldNode);
1455
+ });
1456
+ if (configs.length) {
1457
+ await this.update(configs, { historyDescription, historySource: 'rollback' });
1458
+ }
1459
+ break;
1460
+ }
1461
+ }
1462
+ } finally {
1463
+ historyService.off('change', captureRevert);
1464
+ }
1465
+
1466
+ // 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
1467
+ const page = toRaw(this.get('page'));
1468
+ if (page) {
1469
+ this.emit('history-change', page as MPage | MPageFragment);
1470
+ }
1471
+
1472
+ return revertedStep;
1473
+ }
1474
+
1475
+ /**
1476
+ * 通过历史记录 uuid 回滚当前页面的历史步骤,语义与 {@link revertPageStep} 完全一致,
1477
+ * 仅入参从 index 改为 uuid 列表({@link StepValue.uuid})。按数组顺序依次回滚,
1478
+ * 返回与入参同序的结果列表(某项失败时为 `null`)。
1479
+ *
1480
+ * @param uuids 目标历史记录的 uuid 列表,通常由 *AndGetHistoryId 方法返回的 `historyIds`
1481
+ */
1482
+ public async revertPageStepById(uuids: string[]): Promise<(StepValue | null)[]> {
1483
+ const results: (StepValue | null)[] = [];
1484
+ const pageId = this.get('page')?.id;
1485
+ for (const uuid of uuids) {
1486
+ const location = historyService.findStepLocationByUuid('page', uuid, pageId);
1487
+ results.push(!location ? null : await this.revertPageStep(location.index));
1488
+ }
1489
+ return results;
1490
+ }
1491
+
1492
+ /**
1493
+ * 跳转当前页面历史栈到指定游标位置。
1494
+ *
1495
+ * `targetCursor` 与 `UndoRedo.getCursor()` 同义:表示"已应用步骤数量",
1496
+ * 取值范围 `[0, length]`。当目标 < 当前游标时循环 undo,否则循环 redo。
1497
+ * 通常由历史面板传入「点击的 step.index + 1」作为目标。
1498
+ *
1499
+ * @returns 实际移动到的最终游标位置
1500
+ */
1501
+ public async gotoPageStep(targetCursor: number): Promise<number> {
1502
+ const pageId = this.get('page')?.id;
1503
+ let cursor = historyService.getCursor('page', pageId);
1504
+ const { length } = historyService.getStepList('page', pageId);
1505
+ const target = Math.max(0, Math.min(targetCursor, length));
1506
+ while (cursor > target) {
1507
+ const step = await this.undo();
1508
+ if (!step) break;
1509
+ cursor -= 1;
1510
+ }
1511
+ while (cursor < target) {
1512
+ const step = await this.redo();
1513
+ if (!step) break;
1514
+ cursor += 1;
1515
+ }
1516
+ return cursor;
1517
+ }
1518
+
1519
+ public async move(
1520
+ left: number,
1521
+ top: number,
1522
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1523
+ ) {
1083
1524
  const node = toRaw(this.get('node'));
1084
1525
  if (!node || isPage(node)) return;
1085
1526
 
1086
1527
  const newStyle = calcMoveStyle(node.style || {}, left, top);
1087
1528
  if (!newStyle) return;
1088
1529
 
1089
- await this.update({ id: node.id, type: node.type, style: newStyle });
1530
+ await this.update(
1531
+ { id: node.id, type: node.type, style: newStyle },
1532
+ { doNotPushHistory, historyDescription, historySource },
1533
+ );
1090
1534
  }
1091
1535
 
1092
1536
  public resetState() {
@@ -1134,74 +1578,272 @@ class Editor extends BaseService {
1134
1578
  }
1135
1579
 
1136
1580
  private addModifiedNodeId(id: Id) {
1137
- if (!this.isHistoryStateChange) {
1138
- this.get('modifiedNodeIds').set(id, id);
1139
- }
1581
+ this.get('modifiedNodeIds').set(id, id);
1582
+ }
1583
+
1584
+ /**
1585
+ * 获取指定节点所属的页面 / 页面片:
1586
+ * - 普通节点返回其所在的 page;
1587
+ * - 节点本身就是 page / pageFragment 时返回它自己;
1588
+ * - 找不到时返回 null。
1589
+ * 供 `change` 事件携带「变更节点对应的 page」(而非编辑器当前选中页)。
1590
+ */
1591
+ private getPageOfNode(id: Id): MPage | MPageFragment | null {
1592
+ const { node, page } = this.getNodeInfo(id, false);
1593
+ if (page) return page;
1594
+ if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
1595
+ return null;
1140
1596
  }
1141
1597
 
1142
1598
  private captureSelectionBeforeOp() {
1143
- if (this.isHistoryStateChange || this.selectionBeforeOp) return;
1599
+ if (this.selectionBeforeOp) return;
1144
1600
  this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
1145
1601
  }
1146
1602
 
1147
- private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
1148
- if (this.isHistoryStateChange) {
1149
- this.selectionBeforeOp = null;
1150
- return;
1603
+ /**
1604
+ * 比较「上一次 root」与「新 root」的页面 / 页面片,按页面粒度把整体替换拆成历史记录:
1605
+ * - 新旧都存在且内容变化的页面 → 一条 `update`(整页快照替换,无 changeRecords);
1606
+ * - 仅新 root 存在的页面 → 一条 `add`;
1607
+ * - 仅旧 root 存在的页面 → 一条 `remove`。
1608
+ *
1609
+ * 每条记录落到对应页面自己的历史栈(与普通节点操作一致),并标记来源 `source`。
1610
+ * 内容未变化的页面不产生记录,避免重复设置相同 DSL 时产生噪声。
1611
+ */
1612
+ private pushRootDiffHistory(preRoot: MApp, nextRoot: MApp, source?: HistoryOpSource): void {
1613
+ const prevPages = preRoot?.items || [];
1614
+ const nextPages = nextRoot?.items || [];
1615
+ const prevMap = new Map(prevPages.map((p) => [`${p.id}`, p]));
1616
+ const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
1617
+ const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
1618
+
1619
+ nextPages.forEach((nextPage) => {
1620
+ const prevPage = prevMap.get(`${nextPage.id}`);
1621
+ if (!prevPage) {
1622
+ this.pushPageDiffStep(
1623
+ 'add',
1624
+ nextPage,
1625
+ { newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
1626
+ source,
1627
+ );
1628
+ } else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
1629
+ this.pushPageDiffStep(
1630
+ 'update',
1631
+ nextPage,
1632
+ { oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
1633
+ source,
1634
+ );
1635
+ }
1636
+ });
1637
+
1638
+ prevPages.forEach((prevPage) => {
1639
+ if (!nextMap.has(`${prevPage.id}`)) {
1640
+ this.pushPageDiffStep(
1641
+ 'remove',
1642
+ prevPage,
1643
+ { oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
1644
+ source,
1645
+ );
1646
+ }
1647
+ });
1648
+ }
1649
+
1650
+ /**
1651
+ * 构造一条页面级「set root」历史记录(不携带选区 / modifiedNodeIds 上下文)并落到该页面自己的栈。
1652
+ *
1653
+ * 连续 set root 替换:若该页栈最新一条已是**同来源**的 set root 记录({@link StepValue.rootStep} 且 `source` 相同),
1654
+ * 则用本次记录**替换**它而非新增,避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录;
1655
+ * 来源不同则照常新增(initial 基线不是 rootStep,不在此列)。
1656
+ */
1657
+ private pushPageDiffStep(
1658
+ opType: HistoryOpType,
1659
+ page: MPage | MPageFragment,
1660
+ diffItem: StepDiffItem<MNode>,
1661
+ source?: HistoryOpSource,
1662
+ ): void {
1663
+ const step: StepValue = {
1664
+ uuid: guid(),
1665
+ data: { name: page.name || '', id: page.id },
1666
+ opType,
1667
+ extra: {
1668
+ selectedBefore: [],
1669
+ selectedAfter: [],
1670
+ modifiedNodeIds: new Map(),
1671
+ },
1672
+ diff: [diffItem],
1673
+ rootStep: true,
1674
+ };
1675
+ if (source) step.source = source;
1676
+
1677
+ const top = historyService.getCurrentPageStep(page.id);
1678
+ if (top?.rootStep && top.source === source) {
1679
+ historyService.replaceCurrentStep('page', step, page.id);
1680
+ } else {
1681
+ historyService.push('page', step, page.id);
1151
1682
  }
1683
+ }
1152
1684
 
1685
+ private pushOpHistory(
1686
+ opType: HistoryOpType,
1687
+ {
1688
+ diff,
1689
+ pageData,
1690
+ historyDescription,
1691
+ source,
1692
+ }: {
1693
+ diff: StepDiffItem<MNode>[];
1694
+ pageData: { name: string; id: Id };
1695
+ historyDescription?: string;
1696
+ source?: HistoryOpSource;
1697
+ },
1698
+ ): string | null {
1153
1699
  const step: StepValue = {
1700
+ uuid: guid(),
1154
1701
  data: pageData,
1155
1702
  opType,
1156
- selectedBefore: this.selectionBeforeOp ?? [],
1157
- selectedAfter: this.get('nodes').map((n) => n.id),
1158
- modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
1159
- ...extra,
1703
+ extra: {
1704
+ selectedBefore: this.selectionBeforeOp ?? [],
1705
+ selectedAfter: this.get('nodes').map((n) => n.id),
1706
+ modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
1707
+ },
1708
+ diff,
1160
1709
  };
1161
- historyService.push(step);
1710
+ if (historyDescription) step.historyDescription = historyDescription;
1711
+ if (source) step.source = source;
1712
+ // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
1713
+ // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
1714
+ const pushed = historyService.push('page', step, pageData.id);
1715
+ // push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
1716
+ const historyId = pushed ? step.uuid : null;
1717
+ this.lastPushedHistoryId = historyId;
1162
1718
  this.selectionBeforeOp = null;
1163
- this.isHistoryStateChange = false;
1719
+ return historyId;
1164
1720
  }
1165
1721
 
1166
1722
  /**
1167
1723
  * 应用历史操作(撤销 / 重做)
1724
+ *
1725
+ * 所有 DSL 修改都走 `editor.add / remove / update`,并通过 `doNotPushHistory` 阻止再次入栈、
1726
+ * `doNotSelect / doNotSwitchPage` 让选区由方法末尾的统一逻辑兜底。
1727
+ *
1728
+ * 注意:这些公开方法会发出 add / remove / update 事件,业务侧若需要区分"用户操作"与"撤销重做触发",
1729
+ * 请监听 `history-change` 事件配合判断。
1730
+ *
1168
1731
  * @param step 操作记录
1169
1732
  * @param reverse true = 撤销,false = 重做
1170
1733
  */
1171
1734
  private async applyHistoryOp(step: StepValue, reverse: boolean) {
1172
- this.isHistoryStateChange = true;
1173
-
1735
+ // 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
1736
+ if (step.opType === 'initial') return;
1174
1737
  const root = this.get('root');
1175
1738
  const stage = this.get('stage');
1176
1739
  if (!root) return;
1177
1740
 
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
- };
1741
+ const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const;
1187
1742
 
1188
1743
  switch (step.opType) {
1189
- case 'add':
1190
- await applyHistoryAddOp(step, reverse, ctx);
1744
+ case 'add': {
1745
+ const items = step.diff ?? [];
1746
+ if (reverse) {
1747
+ // 撤销 add:把当时加入的节点删除
1748
+ for (const { newSchema } of items) {
1749
+ if (!newSchema) continue;
1750
+ const existing = this.getNodeById(newSchema.id, false);
1751
+ if (existing) {
1752
+ await this.remove(existing, commonOpts);
1753
+ }
1754
+ }
1755
+ } else {
1756
+ // 重做 add:按记录的 parentId / index 把节点重新插回父容器。
1757
+ // 按目标 index 升序逐个插入,先小后大避免索引漂移
1758
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1759
+ for (const { newSchema, parentId, index } of sorted) {
1760
+ if (!newSchema || parentId === undefined) continue;
1761
+ const parent = this.getNodeById(parentId, false) as MContainer | null;
1762
+ if (parent?.items) {
1763
+ if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
1764
+ parent.items.splice(index, 0, cloneDeep(newSchema));
1765
+ } else {
1766
+ parent.items.push(cloneDeep(newSchema));
1767
+ }
1768
+ await stage?.add({
1769
+ config: cloneDeep(newSchema),
1770
+ parent: cloneDeep(parent),
1771
+ parentId: parent.id,
1772
+ root: cloneDeep(root),
1773
+ });
1774
+ }
1775
+ }
1776
+ }
1191
1777
  break;
1192
- case 'remove':
1193
- await applyHistoryRemoveOp(step, reverse, ctx);
1778
+ }
1779
+ case 'remove': {
1780
+ const items = step.diff ?? [];
1781
+ if (reverse) {
1782
+ // 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
1783
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1784
+ for (const { oldSchema, parentId, index } of sorted) {
1785
+ if (!oldSchema || parentId === undefined) continue;
1786
+ const parent = this.getNodeById(parentId, false) as MContainer | null;
1787
+ if (parent?.items) {
1788
+ parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
1789
+ await stage?.add({
1790
+ config: cloneDeep(oldSchema),
1791
+ parent: cloneDeep(parent),
1792
+ parentId,
1793
+ root: cloneDeep(root),
1794
+ });
1795
+ }
1796
+ }
1797
+ } else {
1798
+ // 重做 remove:再删一次
1799
+ for (const { oldSchema } of items) {
1800
+ if (!oldSchema) continue;
1801
+ const existing = this.getNodeById(oldSchema.id, false);
1802
+ if (existing) {
1803
+ await this.remove(existing, commonOpts);
1804
+ }
1805
+ }
1806
+ }
1194
1807
  break;
1195
- case 'update':
1196
- await applyHistoryUpdateOp(step, reverse, ctx);
1808
+ }
1809
+ case 'update': {
1810
+ const items = step.diff ?? [];
1811
+ // 优先按 changeRecords 局部 patch:仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
1812
+ // 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
1813
+ const configs = items
1814
+ .filter((item) => item.oldSchema && item.newSchema)
1815
+ .map(({ oldSchema, newSchema, changeRecords }) => {
1816
+ const oldNode = oldSchema!;
1817
+ const newNode = newSchema!;
1818
+ if (changeRecords?.length) {
1819
+ const sourceForValues = reverse ? oldNode : newNode;
1820
+ // 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
1821
+ // 后续 update -> mergeWith 会与现有节点深合并,patch 中未涉及的字段不会被改动。
1822
+ const patch: MNode = { id: newNode.id, type: newNode.type };
1823
+ for (const record of changeRecords) {
1824
+ if (!record.propPath) {
1825
+ // 没有 propPath 视为整节点替换
1826
+ return cloneDeep(sourceForValues);
1827
+ }
1828
+ const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
1829
+ setValueByKeyPath(record.propPath, value, patch);
1830
+ }
1831
+ return patch;
1832
+ }
1833
+ return cloneDeep(reverse ? oldNode : newNode);
1834
+ });
1835
+ if (configs.length) {
1836
+ await this.update(configs, { doNotPushHistory: true });
1837
+ }
1197
1838
  break;
1839
+ }
1198
1840
  }
1199
1841
 
1200
- this.set('modifiedNodeIds', step.modifiedNodeIds);
1842
+ this.set('modifiedNodeIds', step.extra?.modifiedNodeIds ?? new Map());
1201
1843
 
1202
1844
  const page = toRaw(this.get('page'));
1203
1845
  if (page) {
1204
- const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
1846
+ const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
1205
1847
  setTimeout(() => {
1206
1848
  if (!selectIds.length) return;
1207
1849
 
@@ -1216,8 +1858,6 @@ class Editor extends BaseService {
1216
1858
  }, 0);
1217
1859
  this.emit('history-change', page as MPage | MPageFragment);
1218
1860
  }
1219
-
1220
- this.isHistoryStateChange = false;
1221
1861
  }
1222
1862
 
1223
1863
  private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {