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

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 (308) hide show
  1. package/dist/es/Editor.vue_vue_type_script_setup_true_lang.js +43 -9
  2. package/dist/es/components/CodeBlockEditor.vue_vue_type_script_setup_true_lang.js +8 -4
  3. package/dist/es/components/CodeParams.vue_vue_type_script_setup_true_lang.js +3 -2
  4. package/dist/es/components/CompareForm.vue_vue_type_script_setup_true_lang.js +22 -98
  5. package/dist/es/components/ContentMenu.vue_vue_type_script_setup_true_lang.js +32 -9
  6. package/dist/es/components/FloatingBox.vue_vue_type_script_setup_true_lang.js +68 -17
  7. package/dist/es/components/Icon.vue_vue_type_script_setup_true_lang.js +1 -1
  8. package/dist/es/components/Resizer.vue_vue_type_script_setup_true_lang.js +1 -1
  9. package/dist/es/components/ScrollBar.js +0 -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/components/ViewForm.js +5 -0
  18. package/dist/es/components/ViewForm.vue_vue_type_script_setup_true_lang.js +58 -0
  19. package/dist/es/editorProps.js +9 -1
  20. package/dist/es/fields/Code.vue_vue_type_script_setup_true_lang.js +16 -5
  21. package/dist/es/fields/CodeLink.vue_vue_type_script_setup_true_lang.js +7 -7
  22. package/dist/es/fields/CodeSelect.vue_vue_type_script_setup_true_lang.js +81 -44
  23. package/dist/es/fields/CodeSelectCol.vue_vue_type_script_setup_true_lang.js +10 -3
  24. package/dist/es/fields/CondOpSelect.vue_vue_type_script_setup_true_lang.js +7 -19
  25. package/dist/es/fields/DataSourceFieldSelect/FieldSelect.vue_vue_type_script_setup_true_lang.js +13 -6
  26. package/dist/es/fields/DataSourceFieldSelect/Index.vue_vue_type_script_setup_true_lang.js +11 -8
  27. package/dist/es/fields/DataSourceFields.vue_vue_type_script_setup_true_lang.js +39 -12
  28. package/dist/es/fields/DataSourceInput.vue_vue_type_script_setup_true_lang.js +11 -20
  29. package/dist/es/fields/DataSourceMethodSelect.vue_vue_type_script_setup_true_name_true_lang.js +11 -6
  30. package/dist/es/fields/DataSourceMethods.vue_vue_type_script_setup_true_lang.js +30 -14
  31. package/dist/es/fields/DataSourceMocks.vue_vue_type_script_setup_true_lang.js +11 -4
  32. package/dist/es/fields/DataSourceSelect.vue_vue_type_script_setup_true_lang.js +5 -2
  33. package/dist/es/fields/DisplayConds.vue_vue_type_script_setup_true_lang.js +38 -6
  34. package/dist/es/fields/EventSelect.vue_vue_type_script_setup_true_lang.js +156 -155
  35. package/dist/es/fields/KeyValue.vue_vue_type_script_setup_true_lang.js +5 -2
  36. package/dist/es/fields/PageFragmentSelect.vue_vue_type_script_setup_true_lang.js +5 -2
  37. package/dist/es/fields/StyleSetter/Index.vue_vue_type_script_setup_true_lang.js +8 -10
  38. package/dist/es/fields/StyleSetter/components/BackgroundPosition.vue_vue_type_script_setup_true_lang.js +5 -2
  39. package/dist/es/fields/StyleSetter/components/Border.vue_vue_type_script_setup_true_lang.js +28 -6
  40. package/dist/es/fields/StyleSetter/components/Box.vue_vue_type_script_setup_true_lang.js +1 -1
  41. package/dist/es/fields/StyleSetter/icons/align-items/Center.js +31 -0
  42. package/dist/es/fields/StyleSetter/icons/align-items/FlexEnd.js +31 -0
  43. package/dist/es/fields/StyleSetter/icons/align-items/FlexStart.js +31 -0
  44. package/dist/es/fields/StyleSetter/icons/align-items/SpaceAround.js +50 -0
  45. package/dist/es/fields/StyleSetter/icons/align-items/SpaceBetween.js +50 -0
  46. package/dist/es/fields/StyleSetter/icons/background-repeat/NoRepeat.js +7 -7
  47. package/dist/es/fields/StyleSetter/icons/background-repeat/Repeat.js +6 -3
  48. package/dist/es/fields/StyleSetter/icons/background-repeat/RepeatX.js +29 -6
  49. package/dist/es/fields/StyleSetter/icons/background-repeat/RepeatY.js +32 -6
  50. package/dist/es/fields/StyleSetter/icons/display/Block.js +1 -1
  51. package/dist/es/fields/StyleSetter/icons/display/Flex.js +1 -1
  52. package/dist/es/fields/StyleSetter/icons/display/Inline.js +1 -1
  53. package/dist/es/fields/StyleSetter/icons/display/InlineBlock.js +1 -1
  54. package/dist/es/fields/StyleSetter/icons/display/None.js +1 -1
  55. package/dist/es/fields/StyleSetter/icons/flex-direction/Column.js +11 -3
  56. package/dist/es/fields/StyleSetter/icons/flex-direction/ColumnReverse.js +11 -3
  57. package/dist/es/fields/StyleSetter/icons/flex-direction/Row.js +11 -3
  58. package/dist/es/fields/StyleSetter/icons/flex-direction/RowReverse.js +11 -3
  59. package/dist/es/fields/StyleSetter/icons/justify-content/Center.js +17 -3
  60. package/dist/es/fields/StyleSetter/icons/justify-content/FlexEnd.js +19 -3
  61. package/dist/es/fields/StyleSetter/icons/justify-content/FlexStart.js +19 -3
  62. package/dist/es/fields/StyleSetter/icons/justify-content/SpaceAround.js +37 -3
  63. package/dist/es/fields/StyleSetter/icons/justify-content/SpaceBetween.js +33 -3
  64. package/dist/es/fields/StyleSetter/icons/text-align/Center.js +1 -1
  65. package/dist/es/fields/StyleSetter/icons/text-align/Left.js +1 -1
  66. package/dist/es/fields/StyleSetter/icons/text-align/Right.js +1 -1
  67. package/dist/es/fields/StyleSetter/pro/Background.vue_vue_type_script_setup_true_lang.js +56 -30
  68. package/dist/es/fields/StyleSetter/pro/Border.vue_vue_type_script_setup_true_lang.js +7 -4
  69. package/dist/es/fields/StyleSetter/pro/Font.vue_vue_type_script_setup_true_lang.js +35 -26
  70. package/dist/es/fields/StyleSetter/pro/Layout.vue_vue_type_script_setup_true_lang.js +76 -48
  71. package/dist/es/fields/StyleSetter/pro/Position.vue_vue_type_script_setup_true_lang.js +29 -23
  72. package/dist/es/fields/StyleSetter/pro/Transform.vue_vue_type_script_setup_true_lang.js +7 -4
  73. package/dist/es/fields/UISelect.js +0 -1
  74. package/dist/es/fields/UISelect.vue_vue_type_script_setup_true_lang.js +5 -2
  75. package/dist/es/hooks/use-code-block-edit.js +6 -3
  76. package/dist/es/hooks/use-compare-form.js +119 -0
  77. package/dist/es/hooks/use-data-source-edit.js +5 -2
  78. package/dist/es/hooks/use-stage.js +9 -4
  79. package/dist/es/icons/CenterIcon.vue_vue_type_script_setup_true_lang.js +1 -1
  80. package/dist/es/icons/CodeIcon.vue_vue_type_script_setup_true_lang.js +1 -1
  81. package/dist/es/icons/DatasourceIcon.js +17 -0
  82. package/dist/es/icons/FolderMinusIcon.vue_vue_type_script_setup_true_lang.js +1 -1
  83. package/dist/es/index.js +24 -10
  84. package/dist/es/initService.js +19 -13
  85. package/dist/es/layouts/AddPageBox.vue_vue_type_script_setup_true_lang.js +1 -1
  86. package/dist/es/layouts/CodeEditor.vue_vue_type_script_setup_true_lang.js +24 -12
  87. package/dist/es/layouts/Framework.vue_vue_type_script_setup_true_lang.js +25 -18
  88. package/dist/es/layouts/NavMenu.vue_vue_type_script_setup_true_lang.js +4 -4
  89. package/dist/es/layouts/NavMenuColumn.vue_vue_type_script_setup_true_lang.js +1 -1
  90. package/dist/es/layouts/history-list/Bucket.vue_vue_type_script_setup_true_lang.js +25 -38
  91. package/dist/es/layouts/history-list/BucketTab.js +5 -0
  92. package/dist/es/layouts/history-list/BucketTab.vue_vue_type_script_setup_true_lang.js +62 -0
  93. package/dist/es/layouts/history-list/GroupRow.vue_vue_type_script_setup_true_lang.js +177 -88
  94. package/dist/es/layouts/history-list/HistoryDiffDialog.vue_vue_type_script_setup_true_lang.js +139 -37
  95. package/dist/es/layouts/history-list/HistoryListPanel.vue_vue_type_script_setup_true_lang.js +164 -107
  96. package/dist/es/layouts/history-list/InitialRow.vue_vue_type_script_setup_true_lang.js +44 -9
  97. package/dist/es/layouts/history-list/PageTab.vue_vue_type_script_setup_true_lang.js +49 -53
  98. package/dist/es/layouts/history-list/composables.js +138 -108
  99. package/dist/es/layouts/history-list/useHistoryList.js +56 -0
  100. package/dist/es/layouts/history-list/useHistoryRevert.js +317 -0
  101. package/dist/es/layouts/page-bar/AddButton.vue_vue_type_script_setup_true_lang.js +1 -1
  102. package/dist/es/layouts/page-bar/PageBar.vue_vue_type_script_setup_true_lang.js +5 -3
  103. package/dist/es/layouts/page-bar/PageBarScrollContainer.vue_vue_type_script_setup_true_lang.js +1 -1
  104. package/dist/es/layouts/page-bar/PageList.vue_vue_type_script_setup_true_lang.js +1 -1
  105. package/dist/es/layouts/page-bar/Search.vue_vue_type_script_setup_true_lang.js +2 -2
  106. package/dist/es/layouts/props-panel/FormPanel.vue_vue_type_script_setup_true_lang.js +50 -7
  107. package/dist/es/layouts/props-panel/PropsPanel.vue_vue_type_script_setup_true_lang.js +33 -13
  108. package/dist/es/layouts/props-panel/use-style-panel.js +3 -2
  109. package/dist/es/layouts/sidebar/ComponentListPanel.vue_vue_type_script_setup_true_lang.js +3 -3
  110. package/dist/es/layouts/sidebar/Sidebar.vue_vue_type_script_setup_true_lang.js +26 -38
  111. package/dist/es/layouts/sidebar/code-block/CodeBlockList.vue_vue_type_script_setup_true_lang.js +4 -4
  112. package/dist/es/layouts/sidebar/code-block/CodeBlockListPanel.vue_vue_type_script_setup_true_lang.js +4 -4
  113. package/dist/es/layouts/sidebar/code-block/useContentMenu.js +19 -4
  114. package/dist/es/layouts/sidebar/data-source/DataSourceAddButton.vue_vue_type_script_setup_true_lang.js +1 -1
  115. package/dist/es/layouts/sidebar/data-source/DataSourceConfigPanel.vue_vue_type_script_setup_true_lang.js +23 -6
  116. package/dist/es/layouts/sidebar/data-source/DataSourceList.vue_vue_type_script_setup_true_lang.js +1 -1
  117. package/dist/es/layouts/sidebar/data-source/DataSourceListPanel.vue_vue_type_script_setup_true_lang.js +27 -5
  118. package/dist/es/layouts/sidebar/data-source/useContentMenu.js +19 -4
  119. package/dist/es/layouts/sidebar/layer/LayerMenu.vue_vue_type_script_setup_true_lang.js +8 -8
  120. package/dist/es/layouts/sidebar/layer/LayerNodeContent.js +5 -0
  121. package/dist/es/layouts/sidebar/layer/LayerNodeContent.vue_vue_type_script_setup_true_lang.js +46 -0
  122. package/dist/es/layouts/sidebar/layer/LayerNodeTool.vue_vue_type_script_setup_true_lang.js +2 -2
  123. package/dist/es/layouts/sidebar/layer/LayerPanel.vue_vue_type_script_setup_true_lang.js +3 -2
  124. package/dist/es/layouts/sidebar/layer/use-click.js +3 -3
  125. package/dist/es/layouts/sidebar/layer/use-node-status.js +2 -2
  126. package/dist/es/layouts/workspace/Breadcrumb.vue_vue_type_script_setup_true_lang.js +1 -1
  127. package/dist/es/layouts/workspace/Workspace.vue_vue_type_script_setup_true_lang.js +1 -1
  128. package/dist/es/layouts/workspace/viewer/NodeListMenu.vue_vue_type_script_setup_true_lang.js +9 -3
  129. package/dist/es/layouts/workspace/viewer/Stage.vue_vue_type_script_setup_true_lang.js +2 -2
  130. package/dist/es/layouts/workspace/viewer/StageOverlay.vue_vue_type_script_setup_true_lang.js +1 -1
  131. package/dist/es/layouts/workspace/viewer/ViewerMenu.vue_vue_type_script_setup_true_lang.js +16 -16
  132. package/dist/es/plugin.js +5 -2
  133. package/dist/es/services/codeBlock.js +137 -56
  134. package/dist/es/services/dataSource.js +122 -52
  135. package/dist/es/services/dep.js +162 -41
  136. package/dist/es/services/editor.js +613 -179
  137. package/dist/es/services/events.js +2 -2
  138. package/dist/es/services/history.js +350 -386
  139. package/dist/es/services/keybinding.js +6 -6
  140. package/dist/es/services/props.js +3 -2
  141. package/dist/es/services/storage.js +2 -2
  142. package/dist/es/services/ui.js +10 -2
  143. package/dist/es/style.css +549 -113
  144. package/dist/es/utils/config.js +4 -1
  145. package/dist/es/utils/const.js +2 -2
  146. package/dist/es/utils/content-menu.js +19 -11
  147. package/dist/es/utils/data-source/index.js +34 -11
  148. package/dist/es/utils/dep/collect-worker-client.js +114 -0
  149. package/dist/es/utils/dep/idle-task.js +70 -22
  150. package/dist/es/utils/dep/worker.js +1 -1
  151. package/dist/es/utils/editor.js +103 -49
  152. package/dist/es/utils/event.js +135 -0
  153. package/dist/es/utils/history.js +220 -0
  154. package/dist/es/utils/indexed-db.js +86 -0
  155. package/dist/es/utils/monaco-editor.js +22 -6
  156. package/dist/es/utils/operator.js +2 -2
  157. package/dist/es/utils/props.js +114 -33
  158. package/dist/es/utils/type-match-rules.js +367 -0
  159. package/dist/es/utils/undo-redo.js +60 -1
  160. package/dist/style.css +549 -113
  161. package/dist/themes/magic-admin.css +3028 -0
  162. package/dist/tmagic-editor.umd.cjs +14403 -10121
  163. package/package.json +8 -8
  164. package/src/Editor.vue +43 -2
  165. package/src/components/CodeBlockEditor.vue +3 -0
  166. package/src/components/CompareForm.vue +18 -146
  167. package/src/components/ContentMenu.vue +44 -8
  168. package/src/components/FloatingBox.vue +84 -9
  169. package/src/components/ScrollBar.vue +0 -67
  170. package/src/components/ToolButton.vue +2 -2
  171. package/src/components/ViewForm.vue +55 -0
  172. package/src/editorProps.ts +30 -0
  173. package/src/fields/Code.vue +10 -2
  174. package/src/fields/CodeLink.vue +2 -5
  175. package/src/fields/CodeSelect.vue +54 -40
  176. package/src/fields/CodeSelectCol.vue +8 -1
  177. package/src/fields/CondOpSelect.vue +2 -24
  178. package/src/fields/DataSourceFieldSelect/FieldSelect.vue +16 -2
  179. package/src/fields/DataSourceFieldSelect/Index.vue +14 -5
  180. package/src/fields/DataSourceFields.vue +42 -8
  181. package/src/fields/DataSourceInput.vue +10 -28
  182. package/src/fields/DataSourceMethodSelect.vue +9 -4
  183. package/src/fields/DataSourceMethods.vue +42 -21
  184. package/src/fields/DataSourceMocks.vue +3 -0
  185. package/src/fields/DisplayConds.vue +18 -1
  186. package/src/fields/EventSelect.vue +152 -166
  187. package/src/fields/StyleSetter/Index.vue +3 -12
  188. package/src/fields/StyleSetter/components/Border.vue +15 -6
  189. package/src/fields/StyleSetter/icons/align-items/Center.vue +19 -0
  190. package/src/fields/StyleSetter/icons/align-items/FlexEnd.vue +19 -0
  191. package/src/fields/StyleSetter/icons/align-items/FlexStart.vue +19 -0
  192. package/src/fields/StyleSetter/icons/align-items/SpaceAround.vue +34 -0
  193. package/src/fields/StyleSetter/icons/align-items/SpaceBetween.vue +34 -0
  194. package/src/fields/StyleSetter/icons/align-items/index.ts +5 -0
  195. package/src/fields/StyleSetter/icons/background-repeat/NoRepeat.vue +20 -4
  196. package/src/fields/StyleSetter/icons/background-repeat/Repeat.vue +10 -28
  197. package/src/fields/StyleSetter/icons/background-repeat/RepeatX.vue +4 -5
  198. package/src/fields/StyleSetter/icons/background-repeat/RepeatY.vue +4 -5
  199. package/src/fields/StyleSetter/icons/flex-direction/Column.vue +4 -3
  200. package/src/fields/StyleSetter/icons/flex-direction/ColumnReverse.vue +4 -3
  201. package/src/fields/StyleSetter/icons/flex-direction/Row.vue +4 -3
  202. package/src/fields/StyleSetter/icons/flex-direction/RowReverse.vue +4 -3
  203. package/src/fields/StyleSetter/icons/justify-content/Center.vue +5 -2
  204. package/src/fields/StyleSetter/icons/justify-content/FlexEnd.vue +15 -2
  205. package/src/fields/StyleSetter/icons/justify-content/FlexStart.vue +15 -2
  206. package/src/fields/StyleSetter/icons/justify-content/SpaceAround.vue +28 -3
  207. package/src/fields/StyleSetter/icons/justify-content/SpaceBetween.vue +28 -2
  208. package/src/fields/StyleSetter/pro/Background.vue +99 -55
  209. package/src/fields/StyleSetter/pro/Border.vue +9 -11
  210. package/src/fields/StyleSetter/pro/Font.vue +66 -64
  211. package/src/fields/StyleSetter/pro/Layout.vue +211 -139
  212. package/src/fields/StyleSetter/pro/Position.vue +69 -67
  213. package/src/fields/StyleSetter/pro/Transform.vue +23 -25
  214. package/src/fields/UISelect.vue +0 -12
  215. package/src/hooks/index.ts +1 -0
  216. package/src/hooks/use-code-block-edit.ts +4 -3
  217. package/src/hooks/use-compare-form.ts +187 -0
  218. package/src/hooks/use-data-source-edit.ts +2 -2
  219. package/src/hooks/use-stage.ts +5 -3
  220. package/src/icons/DatasourceIcon.vue +7 -0
  221. package/src/index.ts +12 -0
  222. package/src/initService.ts +26 -16
  223. package/src/layouts/CodeEditor.vue +34 -11
  224. package/src/layouts/Framework.vue +15 -8
  225. package/src/layouts/NavMenu.vue +3 -3
  226. package/src/layouts/history-list/Bucket.vue +33 -41
  227. package/src/layouts/history-list/BucketTab.vue +72 -0
  228. package/src/layouts/history-list/GroupRow.vue +190 -93
  229. package/src/layouts/history-list/HistoryDiffDialog.vue +169 -90
  230. package/src/layouts/history-list/HistoryListPanel.vue +191 -95
  231. package/src/layouts/history-list/InitialRow.vue +38 -8
  232. package/src/layouts/history-list/PageTab.vue +67 -43
  233. package/src/layouts/history-list/composables.ts +239 -125
  234. package/src/layouts/history-list/useHistoryList.ts +60 -0
  235. package/src/layouts/history-list/useHistoryRevert.ts +425 -0
  236. package/src/layouts/page-bar/PageBar.vue +4 -2
  237. package/src/layouts/props-panel/FormPanel.vue +72 -6
  238. package/src/layouts/props-panel/PropsPanel.vue +62 -18
  239. package/src/layouts/props-panel/use-style-panel.ts +4 -3
  240. package/src/layouts/sidebar/ComponentListPanel.vue +10 -6
  241. package/src/layouts/sidebar/Sidebar.vue +13 -6
  242. package/src/layouts/sidebar/code-block/CodeBlockList.vue +5 -5
  243. package/src/layouts/sidebar/code-block/CodeBlockListPanel.vue +5 -2
  244. package/src/layouts/sidebar/code-block/useContentMenu.ts +14 -2
  245. package/src/layouts/sidebar/data-source/DataSourceConfigPanel.vue +34 -2
  246. package/src/layouts/sidebar/data-source/DataSourceListPanel.vue +29 -4
  247. package/src/layouts/sidebar/data-source/useContentMenu.ts +14 -2
  248. package/src/layouts/sidebar/layer/LayerMenu.vue +21 -13
  249. package/src/layouts/sidebar/layer/LayerNodeContent.vue +57 -0
  250. package/src/layouts/sidebar/layer/LayerNodeTool.vue +7 -4
  251. package/src/layouts/sidebar/layer/LayerPanel.vue +4 -1
  252. package/src/layouts/sidebar/layer/use-click.ts +3 -3
  253. package/src/layouts/sidebar/layer/use-node-status.ts +2 -2
  254. package/src/layouts/workspace/viewer/NodeListMenu.vue +4 -1
  255. package/src/layouts/workspace/viewer/Stage.vue +1 -1
  256. package/src/layouts/workspace/viewer/ViewerMenu.vue +15 -15
  257. package/src/plugin.ts +6 -3
  258. package/src/services/codeBlock.ts +152 -47
  259. package/src/services/dataSource.ts +120 -41
  260. package/src/services/dep.ts +292 -47
  261. package/src/services/editor.ts +820 -224
  262. package/src/services/events.ts +3 -3
  263. package/src/services/history.ts +449 -412
  264. package/src/services/keybinding.ts +6 -6
  265. package/src/services/ui.ts +33 -5
  266. package/src/theme/code-block.scss +37 -0
  267. package/src/theme/common/var.scss +1 -1
  268. package/src/theme/component-list-panel.scss +2 -2
  269. package/src/theme/data-source-field.scss +4 -0
  270. package/src/theme/display-conds.scss +11 -0
  271. package/src/theme/event.scss +70 -1
  272. package/src/theme/floating-box.scss +9 -1
  273. package/src/theme/framework.scss +4 -1
  274. package/src/theme/history-list-panel.scss +157 -23
  275. package/src/theme/layer-node-content.scss +14 -0
  276. package/src/theme/nav-menu.scss +5 -1
  277. package/src/theme/page-bar.scss +0 -4
  278. package/src/theme/props-panel.scss +5 -4
  279. package/src/theme/scroll-bar.scss +64 -0
  280. package/src/theme/style-setter/border.scss +17 -5
  281. package/src/theme/theme.scss +4 -0
  282. package/src/theme/themes/magic-admin/index.scss +214 -0
  283. package/src/theme/ui-select.scss +9 -0
  284. package/src/type.ts +668 -124
  285. package/src/utils/config.ts +9 -1
  286. package/src/utils/const.ts +3 -2
  287. package/src/utils/content-menu.ts +20 -11
  288. package/src/utils/data-source/index.ts +32 -12
  289. package/src/utils/dep/collect-worker-client.ts +149 -0
  290. package/src/utils/dep/idle-task.ts +104 -35
  291. package/src/utils/dep/worker.ts +113 -9
  292. package/src/utils/editor.ts +166 -60
  293. package/src/utils/event.ts +237 -0
  294. package/src/utils/history.ts +286 -0
  295. package/src/utils/index.ts +4 -0
  296. package/src/utils/indexed-db.ts +122 -0
  297. package/src/utils/monaco-editor.ts +27 -0
  298. package/src/utils/operator.ts +2 -2
  299. package/src/utils/props.ts +114 -11
  300. package/src/utils/type-match-rules.ts +684 -0
  301. package/src/utils/undo-redo.ts +88 -0
  302. package/types/index.d.ts +1990 -517
  303. package/dist/es/layouts/history-list/CodeBlockTab.js +0 -5
  304. package/dist/es/layouts/history-list/CodeBlockTab.vue_vue_type_script_setup_true_lang.js +0 -62
  305. package/dist/es/layouts/history-list/DataSourceTab.js +0 -5
  306. package/dist/es/layouts/history-list/DataSourceTab.vue_vue_type_script_setup_true_lang.js +0 -62
  307. package/src/layouts/history-list/CodeBlockTab.vue +0 -61
  308. package/src/layouts/history-list/DataSourceTab.vue +0 -61
@@ -16,14 +16,24 @@
16
16
  * limitations under the License.
17
17
  */
18
18
 
19
- import { reactive, toRaw } from 'vue';
20
- import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
19
+ import { nextTick, reactive, toRaw } from 'vue';
20
+ import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
21
21
 
22
22
  import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
23
23
  import { NodeType } from '@tmagic/core';
24
24
  import type { ChangeRecord } from '@tmagic/form';
25
25
  import { isFixed } from '@tmagic/stage';
26
- import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils';
26
+ import {
27
+ getNodeInfo,
28
+ getNodePath,
29
+ getValueByKeyPath,
30
+ guid,
31
+ isPage,
32
+ isPageFragment,
33
+ isPageOrFragment,
34
+ setValueByKeyPath,
35
+ traverseNode,
36
+ } from '@tmagic/utils';
27
37
 
28
38
  import BaseService from '@editor/services//BaseService';
29
39
  import propsService from '@editor/services//props';
@@ -34,10 +44,15 @@ import type {
34
44
  AsyncHookPlugin,
35
45
  AsyncMethodName,
36
46
  DslOpOptions,
47
+ DslOpWithHistoryIdsResult,
37
48
  EditorEvents,
38
49
  EditorNodeInfo,
50
+ HistoryOpSource,
39
51
  HistoryOpType,
52
+ NodeInvalidInfo,
53
+ NodeInvalidSource,
40
54
  PastePosition,
55
+ StepDiffItem,
41
56
  StepValue,
42
57
  StoreState,
43
58
  StoreStateKey,
@@ -50,6 +65,7 @@ import {
50
65
  classifyDragSources,
51
66
  collectRelatedNodes,
52
67
  COPY_STORAGE_KEY,
68
+ describeStepForRevert,
53
69
  editorNodeMergeCustomizer,
54
70
  fixNodePosition,
55
71
  getInitPositionStyle,
@@ -62,41 +78,24 @@ import {
62
78
  setLayout,
63
79
  toggleFixedPosition,
64
80
  } from '@editor/utils/editor';
81
+ import { getLastPushedHistoryIds } from '@editor/utils/history';
65
82
  import { beforePaste, getAddParent } from '@editor/utils/operator';
66
83
 
67
84
  type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
68
85
 
69
86
  /**
70
- * 给「回滚」生成的新 step 用的简短描述生成器。
71
- * UI `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。
87
+ * 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
88
+ * `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
89
+ * 缺省(未传 / 空数组)才退化为整节点替换。
72
90
  */
73
- const describeStepForRevert = (step: StepValue): string => {
74
- switch (step.opType) {
75
- case 'add': {
76
- const count = step.nodes?.length ?? 0;
77
- const node = step.nodes?.[0];
78
- const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
79
- return `撤回新增 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
80
- }
81
- case 'remove': {
82
- const count = step.removedItems?.length ?? 0;
83
- const node = step.removedItems?.[0]?.node;
84
- const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
85
- return `还原已删除的 ${count} 个节点${count === 1 && label ? `(${label})` : ''}`;
86
- }
87
- case 'update':
88
- default: {
89
- const items = step.updatedItems ?? [];
90
- if (items.length === 1) {
91
- const { newNode, oldNode, changeRecords } = items[0];
92
- const target = newNode?.name || newNode?.type || oldNode?.name || oldNode?.type || `${newNode?.id ?? ''}`;
93
- const propPath = changeRecords?.[0]?.propPath;
94
- return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
95
- }
96
- return `还原 ${items.length} 个节点的修改`;
97
- }
98
- }
99
- };
91
+ const buildUpdateDiff = (
92
+ items: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[],
93
+ ): StepDiffItem<MNode>[] =>
94
+ items.map(({ oldNode, newNode, changeRecords }) => ({
95
+ oldSchema: oldNode,
96
+ newSchema: newNode,
97
+ ...(changeRecords?.length ? { changeRecords } : {}),
98
+ }));
100
99
 
101
100
  class Editor extends BaseService {
102
101
  public state: StoreState = reactive({
@@ -109,12 +108,24 @@ class Editor extends BaseService {
109
108
  stageLoading: true,
110
109
  highlightNode: null,
111
110
  modifiedNodeIds: new Map(),
111
+ invalidNodeIds: new Map(),
112
112
  pageLength: 0,
113
113
  pageFragmentLength: 0,
114
114
  disabledMultiSelect: false,
115
115
  alwaysMultiSelect: false,
116
116
  });
117
117
  private selectionBeforeOp: Id[] | null = null;
118
+ /**
119
+ * 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获,
120
+ * 供 pushOpHistory 写入 step.extra.invalidNodeIdsBefore,用于撤销时还原到「操作前」的错误状态。
121
+ */
122
+ private invalidNodeIdsBeforeOp: Map<Id, NodeInvalidInfo> | null = null;
123
+ /**
124
+ * 最近一次 pushOpHistory 写入的历史记录 uuid。
125
+ * 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id;
126
+ * 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
127
+ */
128
+ private lastPushedHistoryId: string | null = null;
118
129
 
119
130
  constructor() {
120
131
  super(
@@ -126,10 +137,15 @@ class Editor extends BaseService {
126
137
 
127
138
  /**
128
139
  * 设置当前指点节点配置
129
- * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
140
+ * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength
130
141
  * @param value MNode
142
+ * @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效)
131
143
  */
132
- public set<K extends StoreStateKey, T extends StoreState[K]>(name: K, value: T) {
144
+ public set<K extends StoreStateKey, T extends StoreState[K]>(
145
+ name: K,
146
+ value: T,
147
+ options: { historySource?: HistoryOpSource } = {},
148
+ ) {
133
149
  const preValue = this.state[name];
134
150
  this.state[name] = value;
135
151
 
@@ -148,19 +164,41 @@ class Editor extends BaseService {
148
164
  this.state.pageLength = getPageList(app).length || 0;
149
165
  this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
150
166
  this.state.stageLoading = this.state.pageLength !== 0;
167
+
168
+ if (preValue && !isEmpty(preValue)) {
169
+ // 编辑期间再次整体设置 root(源码保存 / 外部重设 DSL / root 节点更新):与上一次 root
170
+ // 做页面级 diff,按 update / add / remove 入栈,作为正常历史记录体现整体替换。
171
+ this.pushRootDiffHistory(preValue as MApp, app, options.historySource);
172
+ } else {
173
+ // 首次设置 root:仅当该页面 / 页面片尚无基线标记时,才写入「未修改的初始状态」基线。
174
+ // 配合「先恢复历史再 set root」:若基线已随历史恢复建立(恢复后已有基线),则此处不再
175
+ // 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
176
+ // 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
177
+ app.items?.forEach((pageNode) => {
178
+ if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
179
+ historyService.setMarker('page', pageNode.id, {
180
+ name: pageNode.name,
181
+ source: options.historySource,
182
+ });
183
+ }
184
+ });
185
+ }
151
186
  } else {
152
187
  this.state.pageLength = 0;
153
188
  this.state.pageFragmentLength = 0;
154
189
  this.state.stageLoading = false;
155
190
  }
156
191
 
157
- this.emit('root-change', value as StoreState['root'], preValue as StoreState['root']);
192
+ this.emit('root-change', value as StoreState['root'], preValue as StoreState['root'], options);
193
+
194
+ // 整体替换 DSL 后,清理不再存在于新 DSL 中的失效节点错误记录,避免残留误报。
195
+ this.pruneInvalidNodeIds();
158
196
  }
159
197
  }
160
198
 
161
199
  /**
162
200
  * 获取当前指点节点配置
163
- * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength'
201
+ * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength'
164
202
  * @returns MNode
165
203
  */
166
204
  public get<K extends StoreStateKey>(name: K): StoreState[K] {
@@ -179,7 +217,30 @@ class Editor extends BaseService {
179
217
  root = toRaw(root);
180
218
  }
181
219
 
182
- return getNodeInfo(id, root);
220
+ if (!root) {
221
+ return { node: null, parent: null, page: null, path: [] };
222
+ }
223
+
224
+ if (id === root.id) {
225
+ return { node: root, parent: null, page: null, path: [] };
226
+ }
227
+
228
+ // 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
229
+ // 注意:不能直接使用 state.page,它可能与当前 root 不同步(指向已脱离的旧页面对象),
230
+ // 因此仅借用其 id,再从当前 root 中取回真正的页面对象(页面均为 root 的直接子节点,数量很少)。
231
+ const pageIdStr = `${this.get('page')?.id || ''}`;
232
+ const currentPageNode = root.items?.find((item) => `${item.id}` === pageIdStr);
233
+ if (currentPageNode && `${id}` !== pageIdStr) {
234
+ // util 仅读取 root.id 与 root.items,按容器结构传入当前页面是安全的
235
+ const info = getNodeInfo(id, currentPageNode);
236
+ if (info.node) {
237
+ return info;
238
+ }
239
+ }
240
+
241
+ // 回退:在完整 root 上查找;当前页面已搜索过,用 skip 跳过其子树避免重复遍历,
242
+ // 同时保留真实的 parent / page 引用(id 命中当前页面节点本身时会在跳过子树前先匹配到)
243
+ return getNodeInfo(id, root, currentPageNode);
183
244
  }
184
245
 
185
246
  /**
@@ -212,7 +273,7 @@ class Editor extends BaseService {
212
273
  public isOnDifferentPage(node: MNode): boolean {
213
274
  const currentPageId = this.get('page')?.id;
214
275
  if (currentPageId === undefined || currentPageId === null) return false;
215
- if (isPage(node) || isPageFragment(node)) {
276
+ if (isPageOrFragment(node)) {
216
277
  return `${node.id}` !== `${currentPageId}`;
217
278
  }
218
279
  const nodePage = this.getNodeInfo(node.id, false).page;
@@ -249,12 +310,6 @@ class Editor extends BaseService {
249
310
  this.set('page', page);
250
311
  this.set('parent', parent);
251
312
 
252
- if (page) {
253
- historyService.changePage(toRaw(page));
254
- } else {
255
- historyService.resetState();
256
- }
257
-
258
313
  if (node?.id) {
259
314
  this.get('stage')
260
315
  ?.renderer?.runtime?.getApp?.()
@@ -359,11 +414,11 @@ class Editor extends BaseService {
359
414
 
360
415
  if (!curNode) throw new Error('当前选中节点为空');
361
416
 
362
- if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !(isPage(node) || isPageFragment(node))) {
417
+ if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) {
363
418
  throw new Error('app下不能添加组件');
364
419
  }
365
420
 
366
- if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) {
421
+ if (parent.id !== curNode.id && !isPageOrFragment(node)) {
367
422
  const index = parent.items.indexOf(curNode);
368
423
  parent.items?.splice(index + 1, 0, node);
369
424
  } else {
@@ -406,7 +461,13 @@ class Editor extends BaseService {
406
461
  public async add(
407
462
  addNode: AddMNode | MNode[],
408
463
  parent?: MContainer | null,
409
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
464
+ {
465
+ doNotSelect = false,
466
+ doNotSwitchPage = false,
467
+ doNotPushHistory = false,
468
+ historyDescription,
469
+ historySource,
470
+ }: DslOpOptions = {},
410
471
  ): Promise<MNode | MNode[]> {
411
472
  this.captureSelectionBeforeOp();
412
473
 
@@ -427,7 +488,7 @@ class Editor extends BaseService {
427
488
  const newNodes = await Promise.all(
428
489
  addNodes.map((node) => {
429
490
  const root = this.get('root');
430
- if ((isPage(node) || isPageFragment(node)) && root) {
491
+ if (isPageOrFragment(node) && root) {
431
492
  return this.doAdd(node, root);
432
493
  }
433
494
  const parentNode = parent ?? getAddParent(node);
@@ -463,30 +524,42 @@ class Editor extends BaseService {
463
524
  }
464
525
  }
465
526
 
466
- if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
527
+ if (!isPageOrFragment(newNodes[0])) {
467
528
  const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
468
529
  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 },
530
+ const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
531
+ this.pushOpHistory('add', {
532
+ diff: newNodes.map((n) => {
533
+ const p = this.getParentById(n.id, false) as MContainer;
534
+ const idx = p ? getNodeIndex(n.id, p) : -1;
535
+ return {
536
+ newSchema: cloneDeep(toRaw(n)),
537
+ parentId,
538
+ index: typeof idx === 'number' ? idx : -1,
539
+ };
540
+ }),
541
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
482
542
  historyDescription,
483
- );
543
+ source: historySource,
544
+ });
484
545
  } else {
485
546
  this.selectionBeforeOp = null;
486
547
  }
487
548
  }
488
549
 
489
550
  this.emit('add', newNodes);
551
+ this.emit('change', {
552
+ type: 'add',
553
+ data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
554
+ historySource,
555
+ doNotPushHistory,
556
+ });
557
+
558
+ // 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
559
+ const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
560
+ if (addedPages.length) {
561
+ historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
562
+ }
490
563
 
491
564
  return Array.isArray(addNode) ? newNodes : newNodes[0];
492
565
  }
@@ -506,12 +579,14 @@ class Editor extends BaseService {
506
579
 
507
580
  if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点');
508
581
 
509
- parent.items?.splice(index, 1);
510
582
  const stage = this.get('stage');
511
- stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
583
+ const currentPage = this.get('page');
584
+ const isDeletingCurrentPage = isPageOrFragment(node) && !!currentPage && `${currentPage.id}` === `${node.id}`;
585
+
586
+ parent.items?.splice(index, 1);
512
587
 
513
588
  // 始终清理已删除节点在 state 中的残留引用:
514
- // - 即使后续会调用 selectDefault / select(parent) 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
589
+ // - 即使后续会调用 select 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
515
590
  const selectedNodes = this.get('nodes');
516
591
  const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
517
592
  if (removedSelectedIndex !== -1) {
@@ -519,40 +594,44 @@ class Editor extends BaseService {
519
594
  nextSelected.splice(removedSelectedIndex, 1);
520
595
  this.set('nodes', nextSelected);
521
596
  }
522
- if (isPage(node) || isPageFragment(node)) {
523
- const currentPage = this.get('page');
524
- if (currentPage && `${currentPage.id}` === `${node.id}`) {
525
- this.set('page', null);
526
- }
527
- }
528
-
529
- const selectDefault = async (pages: MNode[]) => {
530
- if (pages[0]) {
531
- await this.select(pages[0]);
532
- stage?.select(pages[0].id);
533
- } else {
534
- this.selectRoot();
535
-
536
- historyService.resetPage();
537
- }
538
- };
539
597
 
598
+ const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) };
540
599
  const rootItems = root.items || [];
541
-
542
- if (isPage(node)) {
543
- this.state.pageLength -= 1;
544
-
545
- // 删除页面后默认会切到首个剩余页面(selectDefault),doNotSwitchPage 时跳过这次自动切换
546
- if (!doNotSelect && !doNotSwitchPage) {
547
- await selectDefault(rootItems);
600
+ // 删非当前页时画布不应该变动,所以只有删的是当前页才重新选中
601
+ const shouldReselect = isDeletingCurrentPage && !doNotSelect && !doNotSwitchPage;
602
+ // 还有剩余页面才能切过去,否则退回选中 root
603
+ const shouldSwitchPage = shouldReselect && rootItems.length > 0;
604
+
605
+ if (isPageOrFragment(node)) {
606
+ if (isPage(node)) {
607
+ this.state.pageLength -= 1;
608
+ } else {
609
+ this.state.pageFragmentLength -= 1;
548
610
  }
549
- } else if (isPageFragment(node)) {
550
- this.state.pageFragmentLength -= 1;
551
611
 
552
- if (!doNotSelect && !doNotSwitchPage) {
553
- await selectDefault(rootItems);
612
+ if (shouldSwitchPage) {
613
+ // runtime 收到页面删除时会销毁当前渲染的 page 实例,必须先把画布切到剩余页面再通知删除,
614
+ // 否则画布会被清空,且要等下一次 updatePageId 才能恢复。
615
+ // nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
616
+ await this.select(rootItems[0]);
617
+ stage?.select(rootItems[0].id);
618
+ await nextTick();
619
+ stage?.remove(removeData);
620
+ } else {
621
+ // page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
622
+ // 且不能 await:runtime 未 ready 时 getRuntime 的监听会随 destroy 一起被移除,永远不会 resolve
623
+ stage?.remove(removeData);
624
+
625
+ if (shouldReselect) {
626
+ // 页面已全部删完
627
+ this.selectRoot();
628
+ } else if (isDeletingCurrentPage) {
629
+ this.set('page', null);
630
+ }
554
631
  }
555
632
  } else {
633
+ stage?.remove(removeData);
634
+
556
635
  if (!doNotSelect) {
557
636
  await this.select(parent);
558
637
  stage?.select(parent.id);
@@ -577,15 +656,24 @@ class Editor extends BaseService {
577
656
  */
578
657
  public async remove(
579
658
  nodeOrNodeList: MNode | MNode[],
580
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
659
+ {
660
+ doNotSelect = false,
661
+ doNotSwitchPage = false,
662
+ doNotPushHistory = false,
663
+ historyDescription,
664
+ historySource,
665
+ }: DslOpOptions = {},
581
666
  ): Promise<void> {
582
667
  this.captureSelectionBeforeOp();
583
668
 
584
669
  const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
585
670
 
586
- const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
671
+ // 删除后节点已从树中移除,无法再反查所属 page,这里在删除前先逐个捕获用于 `change` 事件
672
+ const changeItems = nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) }));
673
+
674
+ const removedItems: StepDiffItem<MNode>[] = [];
587
675
  let pageForOp: { name: string; id: Id } | null = null;
588
- if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
676
+ if (!isPageOrFragment(nodes[0])) {
589
677
  for (const n of nodes) {
590
678
  const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
591
679
  if (parent && curNode) {
@@ -594,7 +682,7 @@ class Editor extends BaseService {
594
682
  }
595
683
  const idx = getNodeIndex(curNode.id, parent);
596
684
  removedItems.push({
597
- node: cloneDeep(toRaw(curNode)),
685
+ oldSchema: cloneDeep(toRaw(curNode)),
598
686
  parentId: parent.id,
599
687
  index: typeof idx === 'number' ? idx : -1,
600
688
  });
@@ -604,20 +692,35 @@ class Editor extends BaseService {
604
692
 
605
693
  await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
606
694
 
695
+ // 删除节点时同步清理其(含子树)的校验错误记录;置于 pushOpHistory 之前,使历史快照与本次删除对齐。
696
+ this.removeInvalidNodesBySubtree(nodes);
697
+
607
698
  if (removedItems.length > 0 && pageForOp) {
608
699
  if (!doNotPushHistory) {
609
- this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription);
700
+ this.pushOpHistory('remove', {
701
+ diff: removedItems,
702
+ pageData: pageForOp,
703
+ historyDescription,
704
+ source: historySource,
705
+ });
610
706
  } else {
611
707
  this.selectionBeforeOp = null;
612
708
  }
613
709
  }
614
710
 
615
711
  this.emit('remove', nodes);
712
+ this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
713
+
714
+ // 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
715
+ const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
716
+ if (removedPages.length) {
717
+ historyService.notifyPageStructureChange({ add: [], remove: removedPages });
718
+ }
616
719
  }
617
720
 
618
721
  public async doUpdate(
619
722
  config: MNode,
620
- { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
723
+ { changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
621
724
  ): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
622
725
  const root = this.get('root');
623
726
  if (!root) throw new Error('root为空');
@@ -630,14 +733,14 @@ class Editor extends BaseService {
630
733
 
631
734
  const node = toRaw(info.node);
632
735
 
633
- let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
736
+ let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
634
737
 
635
738
  newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
636
739
 
637
740
  if (!newConfig.type) throw new Error('配置缺少type值');
638
741
 
639
742
  if (newConfig.type === NodeType.ROOT) {
640
- this.set('root', newConfig as MApp);
743
+ this.set('root', newConfig as MApp, { historySource });
641
744
  return {
642
745
  oldNode: node,
643
746
  newNode: newConfig,
@@ -670,7 +773,7 @@ class Editor extends BaseService {
670
773
  }
671
774
 
672
775
  // 只有被更新节点正好是当前选中页面时才同步 state.page,避免「更新非当前页」误将编辑器切到该页
673
- if (isPage(newConfig) || isPageFragment(newConfig)) {
776
+ if (isPageOrFragment(newConfig)) {
674
777
  const currentPage = this.get('page');
675
778
  if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
676
779
  this.set('page', newConfig as MPage | MPageFragment);
@@ -704,11 +807,24 @@ class Editor extends BaseService {
704
807
  changeRecordList?: ChangeRecord[][];
705
808
  doNotPushHistory?: boolean;
706
809
  historyDescription?: string;
810
+ historySource?: HistoryOpSource;
811
+ /**
812
+ * 属性面板提交时携带的校验错误信息,在写入历史记录之前落库,
813
+ * 使历史快照与本次变更对齐,从而 undo/redo 能正确还原错误标记。
814
+ */
815
+ invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string };
707
816
  } = {},
708
817
  ): Promise<MNode | MNode[]> {
709
818
  this.captureSelectionBeforeOp();
710
819
 
711
- const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription } = data;
820
+ const {
821
+ doNotPushHistory = false,
822
+ changeRecordList,
823
+ changeRecords,
824
+ historyDescription,
825
+ historySource,
826
+ invalidInfo,
827
+ } = data;
712
828
 
713
829
  const nodes = Array.isArray(config) ? config : [config];
714
830
 
@@ -717,29 +833,32 @@ class Editor extends BaseService {
717
833
  const updateData = await Promise.all(
718
834
  nodes.map((node, index) => {
719
835
  const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
720
- return this.doUpdate(node, { changeRecords: recordsForNode });
836
+ return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
721
837
  }),
722
838
  );
723
839
 
840
+ // 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。
841
+ this.applyInvalidInfo(config, invalidInfo);
842
+
724
843
  if (updateData[0].oldNode?.type !== NodeType.ROOT) {
725
844
  const curNodes = this.get('nodes');
726
845
  if (curNodes.length) {
727
846
  if (!doNotPushHistory) {
728
847
  const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
729
- this.pushOpHistory(
730
- 'update',
731
- {
732
- updatedItems: updateData.map((d) => ({
848
+ this.pushOpHistory('update', {
849
+ // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
850
+ // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
851
+ diff: buildUpdateDiff(
852
+ updateData.map((d) => ({
733
853
  oldNode: cloneDeep(d.oldNode),
734
- newNode: cloneDeep(toRaw(d.newNode)),
735
- // 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
736
- // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
854
+ newNode: cloneDeep(d.newNode),
737
855
  changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
738
856
  })),
739
- },
740
- { name: pageForOp?.name || '', id: pageForOp!.id },
857
+ ),
858
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
741
859
  historyDescription,
742
- );
860
+ source: historySource,
861
+ });
743
862
  } else {
744
863
  this.selectionBeforeOp = null;
745
864
  }
@@ -747,6 +866,12 @@ class Editor extends BaseService {
747
866
  }
748
867
 
749
868
  this.emit('update', updateData);
869
+ this.emit('change', {
870
+ type: 'update',
871
+ data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
872
+ historySource,
873
+ doNotPushHistory,
874
+ });
750
875
  return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
751
876
  }
752
877
 
@@ -763,7 +888,7 @@ class Editor extends BaseService {
763
888
  public async sort(
764
889
  id1: Id,
765
890
  id2: Id,
766
- { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
891
+ { doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {},
767
892
  ): Promise<void> {
768
893
  this.captureSelectionBeforeOp();
769
894
 
@@ -783,7 +908,7 @@ class Editor extends BaseService {
783
908
 
784
909
  parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
785
910
 
786
- await this.update(parent, { doNotPushHistory });
911
+ await this.update(parent, { doNotPushHistory, historySource });
787
912
  if (!doNotSelect) {
788
913
  await this.select(node);
789
914
  }
@@ -836,7 +961,13 @@ class Editor extends BaseService {
836
961
  public async paste(
837
962
  position: PastePosition = {},
838
963
  collectorOptions?: TargetOptions,
839
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
964
+ {
965
+ doNotSelect = false,
966
+ doNotSwitchPage = false,
967
+ doNotPushHistory = false,
968
+ historyDescription,
969
+ historySource,
970
+ }: DslOpOptions = {},
840
971
  ): Promise<MNode | MNode[] | void> {
841
972
  const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
842
973
  if (!Array.isArray(config)) return;
@@ -857,7 +988,13 @@ class Editor extends BaseService {
857
988
  propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
858
989
  }
859
990
 
860
- return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage, doNotPushHistory });
991
+ return this.add(pasteConfigs, parent, {
992
+ doNotSelect,
993
+ doNotSwitchPage,
994
+ doNotPushHistory,
995
+ historyDescription,
996
+ historySource,
997
+ });
861
998
  }
862
999
 
863
1000
  public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@@ -893,14 +1030,14 @@ class Editor extends BaseService {
893
1030
  */
894
1031
  public async alignCenter(
895
1032
  config: MNode | MNode[],
896
- { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
1033
+ { doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
897
1034
  ): Promise<MNode | MNode[]> {
898
1035
  const nodes = Array.isArray(config) ? config : [config];
899
1036
  const stage = this.get('stage');
900
1037
 
901
1038
  const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
902
1039
 
903
- const newNode = await this.update(newNodes, { doNotPushHistory });
1040
+ const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource });
904
1041
 
905
1042
  if (!doNotSelect) {
906
1043
  if (newNodes.length > 1) {
@@ -919,7 +1056,10 @@ class Editor extends BaseService {
919
1056
  * @param options 可选配置
920
1057
  * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
921
1058
  */
922
- public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise<void> {
1059
+ public async moveLayer(
1060
+ offset: number | LayerOffset,
1061
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1062
+ ): Promise<void> {
923
1063
  this.captureSelectionBeforeOp();
924
1064
 
925
1065
  const root = this.get('root');
@@ -960,16 +1100,26 @@ class Editor extends BaseService {
960
1100
  const pageForOp = this.getNodeInfo(node.id, false).page;
961
1101
  this.pushOpHistory(
962
1102
  'update',
1103
+
963
1104
  {
964
- updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
1105
+ diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
1106
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1107
+ historyDescription,
1108
+ source: historySource,
965
1109
  },
966
- { name: pageForOp?.name || '', id: pageForOp!.id },
967
1110
  );
968
1111
  } else {
969
1112
  this.selectionBeforeOp = null;
970
1113
  }
971
1114
 
972
1115
  this.emit('move-layer', offset);
1116
+ this.emit('change', {
1117
+ type: 'move-layer',
1118
+ data: [{ node, page: this.getPageOfNode(node.id) }],
1119
+ offset,
1120
+ historySource,
1121
+ doNotPushHistory,
1122
+ });
973
1123
  }
974
1124
 
975
1125
  /**
@@ -989,10 +1139,16 @@ class Editor extends BaseService {
989
1139
  public async moveToContainer(
990
1140
  config: MNode | MNode[],
991
1141
  targetId: Id,
992
- { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
1142
+ {
1143
+ doNotSelect = false,
1144
+ doNotSwitchPage = false,
1145
+ doNotPushHistory = false,
1146
+ historyDescription,
1147
+ historySource,
1148
+ }: DslOpOptions = {},
993
1149
  ): Promise<MNode | MNode[]> {
994
1150
  const isBatch = Array.isArray(config);
995
- const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
1151
+ const configs = (isBatch ? config : [config]).filter((item) => !isPageOrFragment(item));
996
1152
 
997
1153
  if (configs.length === 0) {
998
1154
  throw new Error('没有可移动的节点');
@@ -1052,7 +1208,12 @@ class Editor extends BaseService {
1052
1208
  newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
1053
1209
  }));
1054
1210
  const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
1055
- this.pushOpHistory('update', { updatedItems }, historyPage);
1211
+ this.pushOpHistory('update', {
1212
+ diff: buildUpdateDiff(updatedItems),
1213
+ pageData: historyPage,
1214
+ historyDescription,
1215
+ source: historySource,
1216
+ });
1056
1217
  } else {
1057
1218
  this.selectionBeforeOp = null;
1058
1219
  }
@@ -1064,7 +1225,7 @@ class Editor extends BaseService {
1064
1225
  config: MNode | MNode[],
1065
1226
  targetParent: MContainer,
1066
1227
  targetIndex: number,
1067
- { doNotPushHistory = false }: DslOpOptions = {},
1228
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1068
1229
  ) {
1069
1230
  this.captureSelectionBeforeOp();
1070
1231
 
@@ -1127,20 +1288,115 @@ class Editor extends BaseService {
1127
1288
  }
1128
1289
  if (!doNotPushHistory) {
1129
1290
  const pageForOp = this.getNodeInfo(configs[0].id, false).page;
1130
- this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
1291
+ this.pushOpHistory('update', {
1292
+ diff: buildUpdateDiff(updatedItems),
1293
+ pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
1294
+ historyDescription,
1295
+ source: historySource,
1296
+ });
1131
1297
  } else {
1132
1298
  this.selectionBeforeOp = null;
1133
1299
  }
1134
1300
 
1135
1301
  this.emit('drag-to', { targetIndex, configs, targetParent });
1302
+ this.emit('change', {
1303
+ type: 'drag-to',
1304
+ data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
1305
+ targetIndex,
1306
+ targetParent,
1307
+ historySource,
1308
+ doNotPushHistory,
1309
+ });
1136
1310
  }
1137
1311
 
1312
+ // #region AndGetHistoryId
1313
+ /**
1314
+ * 下列 *AndGetHistoryId 方法与对应的普通操作(add / remove / update ...)行为完全一致,
1315
+ * 返回值在 {@link DslOpWithHistoryIdsResult} 中同时包含原操作结果与本次写入历史栈的 uuid 列表({@link StepValue.uuid}),
1316
+ * 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
1317
+ *
1318
+ * 当本次操作未写入历史(doNotPushHistory 为 true、或操作无实际变更 / 提前返回)时 historyIds 为 `[]`。
1319
+ */
1320
+
1321
+ /** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1322
+ public async addAndGetHistoryId(
1323
+ addNode: AddMNode | MNode[],
1324
+ parent?: MContainer | null,
1325
+ options: DslOpOptions = {},
1326
+ ): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
1327
+ this.lastPushedHistoryId = null;
1328
+ const result = await this.add(addNode, parent, options);
1329
+ return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1330
+ }
1331
+
1332
+ /** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1333
+ public async removeAndGetHistoryId(
1334
+ nodeOrNodeList: MNode | MNode[],
1335
+ options: DslOpOptions = {},
1336
+ ): Promise<DslOpWithHistoryIdsResult<void>> {
1337
+ this.lastPushedHistoryId = null;
1338
+ await this.remove(nodeOrNodeList, options);
1339
+ return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1340
+ }
1341
+
1342
+ /** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1343
+ public async updateAndGetHistoryId(
1344
+ config: MNode | MNode[],
1345
+ data: {
1346
+ changeRecords?: ChangeRecord[];
1347
+ changeRecordList?: ChangeRecord[][];
1348
+ doNotPushHistory?: boolean;
1349
+ historyDescription?: string;
1350
+ historySource?: HistoryOpSource;
1351
+ } = {},
1352
+ ): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
1353
+ this.lastPushedHistoryId = null;
1354
+ const result = await this.update(config, data);
1355
+ return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1356
+ }
1357
+
1358
+ /** 等价于 {@link moveLayer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1359
+ public async moveLayerAndGetHistoryId(
1360
+ offset: number | LayerOffset,
1361
+ options: DslOpOptions = {},
1362
+ ): Promise<DslOpWithHistoryIdsResult<void>> {
1363
+ this.lastPushedHistoryId = null;
1364
+ await this.moveLayer(offset, options);
1365
+ return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1366
+ }
1367
+
1368
+ /** 等价于 {@link moveToContainer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1369
+ public async moveToContainerAndGetHistoryId(
1370
+ config: MNode | MNode[],
1371
+ targetId: Id,
1372
+ options: DslOpOptions = {},
1373
+ ): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
1374
+ this.lastPushedHistoryId = null;
1375
+ const result = await this.moveToContainer(config, targetId, options);
1376
+ return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1377
+ }
1378
+
1379
+ /** 等价于 {@link dragTo},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
1380
+ public async dragToAndGetHistoryId(
1381
+ config: MNode | MNode[],
1382
+ targetParent: MContainer,
1383
+ targetIndex: number,
1384
+ options: DslOpOptions = {},
1385
+ ): Promise<DslOpWithHistoryIdsResult<void>> {
1386
+ this.lastPushedHistoryId = null;
1387
+ await this.dragTo(config, targetParent, targetIndex, options);
1388
+ return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
1389
+ }
1390
+ // #endregion AndGetHistoryId
1391
+
1138
1392
  /**
1139
1393
  * 撤销当前操作
1140
1394
  * @returns 被撤销的操作
1141
1395
  */
1142
1396
  public async undo(): Promise<StepValue | null> {
1143
- const value = historyService.undo();
1397
+ const pageId = this.get('page')?.id;
1398
+ if (pageId === undefined) return null;
1399
+ const value = historyService.undo('page', pageId);
1144
1400
  if (value) {
1145
1401
  await this.applyHistoryOp(value, true);
1146
1402
  }
@@ -1152,7 +1408,9 @@ class Editor extends BaseService {
1152
1408
  * @returns 被恢复的操作
1153
1409
  */
1154
1410
  public async redo(): Promise<StepValue | null> {
1155
- const value = historyService.redo();
1411
+ const pageId = this.get('page')?.id;
1412
+ if (pageId === undefined) return null;
1413
+ const value = historyService.redo('page', pageId);
1156
1414
  if (value) {
1157
1415
  await this.applyHistoryOp(value, false);
1158
1416
  }
@@ -1176,32 +1434,41 @@ class Editor extends BaseService {
1176
1434
  * @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
1177
1435
  */
1178
1436
  public async revertPageStep(index: number): Promise<StepValue | null> {
1179
- const list = historyService.getPageStepList();
1437
+ const list = historyService.getStepList('page', this.get('page')?.id);
1180
1438
  const entry = list[index];
1181
1439
  if (!entry?.applied) return null;
1182
1440
 
1183
1441
  const { step } = entry;
1442
+ // 初始基线(index 0 的 initial step)是栈底线,不可回滚。
1443
+ if (step.opType === 'initial') return null;
1184
1444
  const root = this.get('root');
1185
1445
  if (!root) return null;
1186
1446
 
1447
+ // 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
1448
+ if (step.opType === 'update') {
1449
+ const items = step.diff ?? [];
1450
+ if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
1451
+ }
1452
+
1187
1453
  // 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
1188
1454
  let revertedStep: StepValue | null = null;
1189
- const captureRevert = (s: StepValue) => {
1455
+ // page `change` 事件回调签名为 `(pageId, step)`,这里只关心被回滚产生的新 step。
1456
+ const captureRevert = (_pageId: Id, s: StepValue) => {
1190
1457
  revertedStep = s;
1191
1458
  };
1192
1459
  historyService.once('change', captureRevert);
1193
1460
 
1194
1461
  const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
1195
1462
  // revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
1196
- const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription } as const;
1463
+ const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
1197
1464
 
1198
1465
  try {
1199
1466
  switch (step.opType) {
1200
1467
  case 'add': {
1201
1468
  // 原本是新增 → revert 即删除当时被加入的节点
1202
- const nodes = step.nodes ?? [];
1203
- for (const n of nodes) {
1204
- const existing = this.getNodeById(n.id, false);
1469
+ for (const { newSchema } of step.diff ?? []) {
1470
+ if (!newSchema) continue;
1471
+ const existing = this.getNodeById(newSchema.id, false);
1205
1472
  if (existing) {
1206
1473
  await this.remove(existing, opts);
1207
1474
  }
@@ -1211,37 +1478,42 @@ class Editor extends BaseService {
1211
1478
  case 'remove': {
1212
1479
  // 原本是删除 → revert 即把节点按原父容器加回来。
1213
1480
  // 按原 index 升序逐个插回,先小后大避免索引漂移。
1214
- const items = step.removedItems ?? [];
1215
- const sorted = [...items].sort((a, b) => a.index - b.index);
1216
- for (const { node, parentId } of sorted) {
1481
+ const items = step.diff ?? [];
1482
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1483
+ for (const { oldSchema, parentId } of sorted) {
1484
+ if (!oldSchema || parentId === undefined) continue;
1217
1485
  const parent = this.getNodeById(parentId, false) as MContainer | null;
1218
1486
  if (parent) {
1219
- await this.add([cloneDeep(node)] as MNode[], parent, opts);
1487
+ await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
1220
1488
  }
1221
1489
  }
1222
1490
  break;
1223
1491
  }
1224
1492
  case 'update': {
1225
- // 原本是更新 → revert 即把 oldNode 的值写回;
1493
+ // 原本是更新 → revert 即把 oldSchema 的值写回;
1226
1494
  // 优先按 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);
1495
+ const items = step.diff ?? [];
1496
+ const configs = items
1497
+ .filter((item) => item.oldSchema && item.newSchema)
1498
+ .map(({ oldSchema, newSchema, changeRecords }) => {
1499
+ const oldNode = oldSchema!;
1500
+ const newNode = newSchema!;
1501
+ if (changeRecords?.length) {
1502
+ const patch: MNode = { id: newNode.id, type: newNode.type };
1503
+ for (const record of changeRecords) {
1504
+ if (!record.propPath) {
1505
+ // 没有 propPath 视为整节点替换
1506
+ return cloneDeep(oldNode);
1507
+ }
1508
+ const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
1509
+ setValueByKeyPath(record.propPath, value, patch);
1235
1510
  }
1236
- const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
1237
- setValueByKeyPath(record.propPath, value, patch);
1511
+ return patch;
1238
1512
  }
1239
- return patch;
1240
- }
1241
- return cloneDeep(oldNode);
1242
- });
1513
+ return cloneDeep(oldNode);
1514
+ });
1243
1515
  if (configs.length) {
1244
- await this.update(configs, { historyDescription });
1516
+ await this.update(configs, { historyDescription, historySource: 'rollback' });
1245
1517
  }
1246
1518
  break;
1247
1519
  }
@@ -1259,6 +1531,23 @@ class Editor extends BaseService {
1259
1531
  return revertedStep;
1260
1532
  }
1261
1533
 
1534
+ /**
1535
+ * 通过历史记录 uuid 回滚当前页面的历史步骤,语义与 {@link revertPageStep} 完全一致,
1536
+ * 仅入参从 index 改为 uuid 列表({@link StepValue.uuid})。按数组顺序依次回滚,
1537
+ * 返回与入参同序的结果列表(某项失败时为 `null`)。
1538
+ *
1539
+ * @param uuids 目标历史记录的 uuid 列表,通常由 *AndGetHistoryId 方法返回的 `historyIds`
1540
+ */
1541
+ public async revertPageStepById(uuids: string[]): Promise<(StepValue | null)[]> {
1542
+ const results: (StepValue | null)[] = [];
1543
+ const pageId = this.get('page')?.id;
1544
+ for (const uuid of uuids) {
1545
+ const location = historyService.findStepLocationByUuid('page', uuid, pageId);
1546
+ results.push(!location ? null : await this.revertPageStep(location.index));
1547
+ }
1548
+ return results;
1549
+ }
1550
+
1262
1551
  /**
1263
1552
  * 跳转当前页面历史栈到指定游标位置。
1264
1553
  *
@@ -1269,8 +1558,9 @@ class Editor extends BaseService {
1269
1558
  * @returns 实际移动到的最终游标位置
1270
1559
  */
1271
1560
  public async gotoPageStep(targetCursor: number): Promise<number> {
1272
- let cursor = historyService.getPageCursor();
1273
- const { length } = historyService.getPageStepList();
1561
+ const pageId = this.get('page')?.id;
1562
+ let cursor = historyService.getCursor('page', pageId);
1563
+ const { length } = historyService.getStepList('page', pageId);
1274
1564
  const target = Math.max(0, Math.min(targetCursor, length));
1275
1565
  while (cursor > target) {
1276
1566
  const step = await this.undo();
@@ -1285,14 +1575,21 @@ class Editor extends BaseService {
1285
1575
  return cursor;
1286
1576
  }
1287
1577
 
1288
- public async move(left: number, top: number, { doNotPushHistory = false }: DslOpOptions = {}) {
1578
+ public async move(
1579
+ left: number,
1580
+ top: number,
1581
+ { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
1582
+ ) {
1289
1583
  const node = toRaw(this.get('node'));
1290
1584
  if (!node || isPage(node)) return;
1291
1585
 
1292
1586
  const newStyle = calcMoveStyle(node.style || {}, left, top);
1293
1587
  if (!newStyle) return;
1294
1588
 
1295
- await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory });
1589
+ await this.update(
1590
+ { id: node.id, type: node.type, style: newStyle },
1591
+ { doNotPushHistory, historyDescription, historySource },
1592
+ );
1296
1593
  }
1297
1594
 
1298
1595
  public resetState() {
@@ -1304,6 +1601,7 @@ class Editor extends BaseService {
1304
1601
  this.set('stage', null);
1305
1602
  this.set('highlightNode', null);
1306
1603
  this.set('modifiedNodeIds', new Map());
1604
+ this.set('invalidNodeIds', new Map());
1307
1605
  this.set('pageLength', 0);
1308
1606
  }
1309
1607
 
@@ -1317,6 +1615,62 @@ class Editor extends BaseService {
1317
1615
  this.get('modifiedNodeIds').clear();
1318
1616
  }
1319
1617
 
1618
+ /**
1619
+ * 记录(或覆盖)某个节点在指定来源(属性表单 / 样式表单)上的校验错误信息。
1620
+ * @param id 节点 id
1621
+ * @param source 错误来源:'props'(属性表单)| 'style'(样式表单)
1622
+ * @param message 错误文案(可能是包含 <br> 的 HTML)
1623
+ */
1624
+ public setInvalidNode(id: Id, source: NodeInvalidSource, message: string) {
1625
+ const map = this.get('invalidNodeIds');
1626
+ const info: NodeInvalidInfo = { ...(map.get(id) || {}) };
1627
+ info[source] = message;
1628
+ map.set(id, info);
1629
+ this.emit('invalid-node-change', map);
1630
+ }
1631
+
1632
+ /**
1633
+ * 删除节点的校验错误记录。
1634
+ * @param id 节点 id
1635
+ * @param source 指定来源则仅删除该来源;不传则删除该节点全部来源的错误
1636
+ */
1637
+ public deleteInvalidNode(id: Id, source?: NodeInvalidSource) {
1638
+ const map = this.get('invalidNodeIds');
1639
+ if (!map.has(id)) return;
1640
+
1641
+ if (!source) {
1642
+ map.delete(id);
1643
+ } else {
1644
+ const info: NodeInvalidInfo = { ...(map.get(id) || {}) };
1645
+ delete info[source];
1646
+ if (info.props || info.style) {
1647
+ map.set(id, info);
1648
+ } else {
1649
+ map.delete(id);
1650
+ }
1651
+ }
1652
+
1653
+ this.emit('invalid-node-change', map);
1654
+ }
1655
+
1656
+ /** 获取当前存在校验错误的节点错误 Map(key 为节点 id) */
1657
+ public getInvalidNodeIds(): Map<Id, NodeInvalidInfo> {
1658
+ return this.get('invalidNodeIds');
1659
+ }
1660
+
1661
+ /** 获取指定节点的校验错误信息 */
1662
+ public getInvalidNodeInfo(id: Id): NodeInvalidInfo | undefined {
1663
+ return this.get('invalidNodeIds').get(id);
1664
+ }
1665
+
1666
+ /** 清空全部校验错误记录 */
1667
+ public resetInvalidNodeId() {
1668
+ const map = this.get('invalidNodeIds');
1669
+ if (map.size === 0) return;
1670
+ map.clear();
1671
+ this.emit('invalid-node-change', map);
1672
+ }
1673
+
1320
1674
  public usePlugin(options: AsyncHookPlugin<AsyncMethodName, Editor>): void {
1321
1675
  super.usePlugin(options);
1322
1676
  }
@@ -1339,113 +1693,326 @@ class Editor extends BaseService {
1339
1693
  return super.emit(eventName, ...args);
1340
1694
  }
1341
1695
 
1696
+ /**
1697
+ * 应用一次属性面板提交携带的校验错误信息(在写入历史记录之前调用,使历史快照与本次变更对齐)。
1698
+ * - invalidInfo 为空(调用方未携带):认为本次更新对应的节点已无校验错误,清除其全部来源的错误;
1699
+ * - error 非空则记录错误,为空则清除对应来源的错误。
1700
+ */
1701
+ private applyInvalidInfo(
1702
+ config: MNode | MNode[],
1703
+ invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string },
1704
+ ) {
1705
+ if (!invalidInfo) {
1706
+ // 调用方未携带 invalidInfo:本次更新对应节点不应再保留校验错误,清除其全部来源。
1707
+ const ids = (Array.isArray(config) ? config : [config])
1708
+ .map((node) => node.id)
1709
+ .filter((id): id is Id => id !== undefined);
1710
+ ids.forEach((id) => this.deleteInvalidNode(id));
1711
+ return;
1712
+ }
1713
+ const { id, source, error } = invalidInfo;
1714
+ if (error) {
1715
+ this.setInvalidNode(id, source, error);
1716
+ } else {
1717
+ this.deleteInvalidNode(id, source);
1718
+ }
1719
+ }
1720
+
1721
+ /** 删除被移除节点(含其子树)的校验错误记录,避免残留误报 */
1722
+ private removeInvalidNodesBySubtree(nodes: MNode[]) {
1723
+ const map = this.get('invalidNodeIds');
1724
+ if (map.size === 0) return;
1725
+
1726
+ let changed = false;
1727
+ nodes.forEach((node) => {
1728
+ traverseNode(node, (n) => {
1729
+ if (n.id !== undefined && map.delete(n.id)) {
1730
+ changed = true;
1731
+ }
1732
+ });
1733
+ });
1734
+
1735
+ if (changed) {
1736
+ this.emit('invalid-node-change', map);
1737
+ }
1738
+ }
1739
+
1740
+ /** 清理不在当前 DSL 中的失效节点错误记录(用于整体替换 root 后) */
1741
+ private pruneInvalidNodeIds() {
1742
+ const map = this.get('invalidNodeIds');
1743
+ if (map.size === 0) return;
1744
+
1745
+ let changed = false;
1746
+ for (const id of [...map.keys()]) {
1747
+ if (!this.getNodeById(id, false)) {
1748
+ map.delete(id);
1749
+ changed = true;
1750
+ }
1751
+ }
1752
+
1753
+ if (changed) {
1754
+ this.emit('invalid-node-change', map);
1755
+ }
1756
+ }
1757
+
1342
1758
  private addModifiedNodeId(id: Id) {
1343
1759
  this.get('modifiedNodeIds').set(id, id);
1344
1760
  }
1345
1761
 
1762
+ /**
1763
+ * 获取指定节点所属的页面 / 页面片:
1764
+ * - 普通节点返回其所在的 page;
1765
+ * - 节点本身就是 page / pageFragment 时返回它自己;
1766
+ * - 找不到时返回 null。
1767
+ * 供 `change` 事件携带「变更节点对应的 page」(而非编辑器当前选中页)。
1768
+ */
1769
+ private getPageOfNode(id: Id): MPage | MPageFragment | null {
1770
+ const { node, page } = this.getNodeInfo(id, false);
1771
+ if (page) return page;
1772
+ if (node && isPageOrFragment(node)) return node as MPage | MPageFragment;
1773
+ return null;
1774
+ }
1775
+
1346
1776
  private captureSelectionBeforeOp() {
1347
1777
  if (this.selectionBeforeOp) return;
1348
1778
  this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
1779
+ // 与选区快照同步捕获「操作前」的校验错误状态;因 applyInvalidInfo 会在 pushOpHistory 前修改 invalidNodeIds,
1780
+ // 必须在此(操作最开始)留存操作前快照,供 undo 还原。
1781
+ this.invalidNodeIdsBeforeOp = new Map(this.get('invalidNodeIds'));
1782
+ }
1783
+
1784
+ /**
1785
+ * 比较「上一次 root」与「新 root」的页面 / 页面片,按页面粒度把整体替换拆成历史记录:
1786
+ * - 新旧都存在且内容变化的页面 → 一条 `update`(整页快照替换,无 changeRecords);
1787
+ * - 仅新 root 存在的页面 → 一条 `add`;
1788
+ * - 仅旧 root 存在的页面 → 一条 `remove`。
1789
+ *
1790
+ * 每条记录落到对应页面自己的历史栈(与普通节点操作一致),并标记来源 `source`。
1791
+ * 内容未变化的页面不产生记录,避免重复设置相同 DSL 时产生噪声。
1792
+ */
1793
+ private pushRootDiffHistory(preRoot: MApp, nextRoot: MApp, source?: HistoryOpSource): void {
1794
+ const prevPages = preRoot?.items || [];
1795
+ const nextPages = nextRoot?.items || [];
1796
+ const prevMap = new Map(prevPages.map((p) => [`${p.id}`, p]));
1797
+ const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
1798
+ const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
1799
+
1800
+ // 收集本次整体替换中增删的页面,循环结束后合并为一次结构变更通知(避免逐页派发多个事件)
1801
+ const addedPages: (MPage | MPageFragment)[] = [];
1802
+ const removedPages: (MPage | MPageFragment)[] = [];
1803
+
1804
+ nextPages.forEach((nextPage) => {
1805
+ const prevPage = prevMap.get(`${nextPage.id}`);
1806
+ if (!prevPage) {
1807
+ this.pushPageDiffStep(
1808
+ 'add',
1809
+ nextPage,
1810
+ { newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
1811
+ source,
1812
+ );
1813
+ addedPages.push(nextPage);
1814
+ } else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
1815
+ this.pushPageDiffStep(
1816
+ 'update',
1817
+ nextPage,
1818
+ { oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
1819
+ source,
1820
+ );
1821
+ }
1822
+ });
1823
+
1824
+ prevPages.forEach((prevPage) => {
1825
+ if (!nextMap.has(`${prevPage.id}`)) {
1826
+ this.pushPageDiffStep(
1827
+ 'remove',
1828
+ prevPage,
1829
+ { oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
1830
+ source,
1831
+ );
1832
+ removedPages.push(prevPage);
1833
+ }
1834
+ });
1835
+
1836
+ if (addedPages.length || removedPages.length) {
1837
+ historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
1838
+ }
1839
+ }
1840
+
1841
+ /**
1842
+ * 构造一条页面级「set root」历史记录(不携带选区 / modifiedNodeIds 上下文)并落到该页面自己的栈。
1843
+ *
1844
+ * 连续 set root 替换:若该页栈最新一条已是**同来源**的 set root 记录({@link StepValue.rootStep} 且 `source` 相同),
1845
+ * 则用本次记录**替换**它而非新增,避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录;
1846
+ * 来源不同则照常新增(initial 基线不是 rootStep,不在此列)。
1847
+ */
1848
+ private pushPageDiffStep(
1849
+ opType: HistoryOpType,
1850
+ page: MPage | MPageFragment,
1851
+ diffItem: StepDiffItem<MNode>,
1852
+ source?: HistoryOpSource,
1853
+ ): void {
1854
+ const step: StepValue = {
1855
+ uuid: guid(),
1856
+ data: { name: page.name || '', id: page.id },
1857
+ opType,
1858
+ extra: {
1859
+ selectedBefore: [],
1860
+ selectedAfter: [],
1861
+ modifiedNodeIds: new Map(),
1862
+ },
1863
+ diff: [diffItem],
1864
+ rootStep: true,
1865
+ };
1866
+ if (source) step.source = source;
1867
+
1868
+ const top = historyService.getCurrentPageStep(page.id);
1869
+ if (top?.rootStep && top.source === source) {
1870
+ historyService.replaceCurrentStep('page', step, page.id);
1871
+ } else {
1872
+ historyService.push('page', step, page.id);
1873
+ }
1349
1874
  }
1350
1875
 
1351
1876
  private pushOpHistory(
1352
1877
  opType: HistoryOpType,
1353
- extra: Partial<StepValue>,
1354
- pageData: { name: string; id: Id },
1355
- historyDescription?: string,
1356
- ) {
1878
+ {
1879
+ diff,
1880
+ pageData,
1881
+ historyDescription,
1882
+ source,
1883
+ }: {
1884
+ diff: StepDiffItem<MNode>[];
1885
+ pageData: { name: string; id: Id };
1886
+ historyDescription?: string;
1887
+ source?: HistoryOpSource;
1888
+ },
1889
+ ): string | null {
1357
1890
  const step: StepValue = {
1891
+ uuid: guid(),
1358
1892
  data: pageData,
1359
1893
  opType,
1360
- selectedBefore: this.selectionBeforeOp ?? [],
1361
- selectedAfter: this.get('nodes').map((n) => n.id),
1362
- modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
1363
- ...extra,
1894
+ extra: {
1895
+ selectedBefore: this.selectionBeforeOp ?? [],
1896
+ selectedAfter: this.get('nodes').map((n) => n.id),
1897
+ modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
1898
+ // 方向性双快照:undo 还原「操作前」错误状态,redo 还原「操作后」错误状态。
1899
+ invalidNodeIdsBefore: new Map(this.invalidNodeIdsBeforeOp ?? this.get('invalidNodeIds')),
1900
+ invalidNodeIdsAfter: new Map(this.get('invalidNodeIds')),
1901
+ },
1902
+ diff,
1364
1903
  };
1365
1904
  if (historyDescription) step.historyDescription = historyDescription;
1905
+ if (source) step.source = source;
1366
1906
  // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
1367
1907
  // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
1368
- historyService.push(step, pageData.id);
1908
+ const pushed = historyService.push('page', step, pageData.id);
1909
+ // push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
1910
+ const historyId = pushed ? step.uuid : null;
1911
+ this.lastPushedHistoryId = historyId;
1369
1912
  this.selectionBeforeOp = null;
1913
+ this.invalidNodeIdsBeforeOp = null;
1914
+ return historyId;
1370
1915
  }
1371
1916
 
1372
1917
  /**
1373
1918
  * 应用历史操作(撤销 / 重做)
1374
1919
  *
1375
- * 所有 DSL 修改都走 `editor.add / remove / update`,并通过 `doNotPushHistory` 阻止再次入栈、
1376
- * `doNotSelect / doNotSwitchPage` 让选区由方法末尾的统一逻辑兜底。
1920
+ * 删除 / 更新类修改走 `editor.remove / update`,并通过 `doNotPushHistory` 阻止再次入栈、
1921
+ * `doNotSelect / doNotSwitchPage` 让选区由方法末尾的统一逻辑兜底;
1922
+ * 「重新插回节点」(撤销 remove / 重做 add)需按 step 记录的 parentId / index 精确还原,
1923
+ * 不走 this.add,由手工 splice + stage.add 完成后补发等价的 add / change 事件(见 emitHistoryInsertEvents)。
1377
1924
  *
1378
- * 注意:这些公开方法会发出 add / remove / update 事件,业务侧若需要区分"用户操作"与"撤销重做触发",
1925
+ * 注意:这些路径都会发出 add / remove / update / change 事件,业务侧若需要区分"用户操作"与"撤销重做触发",
1379
1926
  * 请监听 `history-change` 事件配合判断。
1380
1927
  *
1381
1928
  * @param step 操作记录
1382
1929
  * @param reverse true = 撤销,false = 重做
1383
1930
  */
1384
1931
  private async applyHistoryOp(step: StepValue, reverse: boolean) {
1932
+ // 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
1933
+ if (step.opType === 'initial') return;
1385
1934
  const root = this.get('root');
1386
1935
  const stage = this.get('stage');
1387
1936
  if (!root) return;
1388
1937
 
1389
- const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const;
1938
+ // 撤销/重做内部复用 add/remove/update,透传被应用 step 上记录的 source,
1939
+ // 使其触发的 change 事件同样携带「历史来源」
1940
+ const commonOpts = {
1941
+ doNotSelect: true,
1942
+ doNotSwitchPage: true,
1943
+ doNotPushHistory: true,
1944
+ historySource: step.source,
1945
+ } as const;
1390
1946
 
1391
1947
  switch (step.opType) {
1392
1948
  case 'add': {
1393
- const nodes = step.nodes ?? [];
1949
+ const items = step.diff ?? [];
1394
1950
  if (reverse) {
1395
1951
  // 撤销 add:把当时加入的节点删除
1396
- for (const n of nodes) {
1397
- const existing = this.getNodeById(n.id, false);
1952
+ for (const { newSchema } of items) {
1953
+ if (!newSchema) continue;
1954
+ const existing = this.getNodeById(newSchema.id, false);
1398
1955
  if (existing) {
1399
1956
  await this.remove(existing, commonOpts);
1400
1957
  }
1401
1958
  }
1402
1959
  } 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
- });
1960
+ // 重做 add:按记录的 parentId / index 把节点重新插回父容器。
1961
+ // 按目标 index 升序逐个插入,先小后大避免索引漂移。
1962
+ // 不走 this.add:doAdd 依赖当前选中节点(无选区时直接抛错),且只能插到选中节点之后 / 末尾,
1963
+ // 无法按 step 记录的 parentId / index 精确还原,还会重算 style 破坏已记录的终态位置。
1964
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1965
+ const addedNodes: MNode[] = [];
1966
+ for (const { newSchema, parentId, index } of sorted) {
1967
+ if (!newSchema || parentId === undefined) continue;
1968
+ const parent = this.getNodeById(parentId, false) as MContainer | null;
1969
+ if (parent?.items) {
1970
+ const addedNode = cloneDeep(newSchema);
1971
+ if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
1972
+ parent.items.splice(index, 0, addedNode);
1973
+ } else {
1974
+ parent.items.push(addedNode);
1422
1975
  }
1976
+ addedNodes.push(addedNode);
1977
+ await stage?.add({
1978
+ config: cloneDeep(newSchema),
1979
+ parent: cloneDeep(parent),
1980
+ parentId: parent.id,
1981
+ root: cloneDeep(root),
1982
+ });
1423
1983
  }
1424
1984
  }
1985
+ this.emitHistoryInsertEvents(addedNodes, step.source);
1425
1986
  }
1426
1987
  break;
1427
1988
  }
1428
1989
  case 'remove': {
1429
- const items = step.removedItems ?? [];
1990
+ const items = step.diff ?? [];
1430
1991
  if (reverse) {
1431
- // 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
1432
- const sorted = [...items].sort((a, b) => a.index - b.index);
1433
- for (const { node, parentId, index } of sorted) {
1992
+ // 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移);不走 this.add 的原因同上
1993
+ const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1994
+ const addedNodes: MNode[] = [];
1995
+ for (const { oldSchema, parentId, index } of sorted) {
1996
+ if (!oldSchema || parentId === undefined) continue;
1434
1997
  const parent = this.getNodeById(parentId, false) as MContainer | null;
1435
1998
  if (parent?.items) {
1436
- parent.items.splice(index, 0, cloneDeep(node));
1999
+ const addedNode = cloneDeep(oldSchema);
2000
+ parent.items.splice(index ?? parent.items.length, 0, addedNode);
2001
+ addedNodes.push(addedNode);
1437
2002
  await stage?.add({
1438
- config: cloneDeep(node),
2003
+ config: cloneDeep(oldSchema),
1439
2004
  parent: cloneDeep(parent),
1440
2005
  parentId,
1441
2006
  root: cloneDeep(root),
1442
2007
  });
1443
2008
  }
1444
2009
  }
2010
+ this.emitHistoryInsertEvents(addedNodes, step.source);
1445
2011
  } else {
1446
2012
  // 重做 remove:再删一次
1447
- for (const { node } of items) {
1448
- const existing = this.getNodeById(node.id, false);
2013
+ for (const { oldSchema } of items) {
2014
+ if (!oldSchema) continue;
2015
+ const existing = this.getNodeById(oldSchema.id, false);
1449
2016
  if (existing) {
1450
2017
  await this.remove(existing, commonOpts);
1451
2018
  }
@@ -1454,39 +2021,52 @@ class Editor extends BaseService {
1454
2021
  break;
1455
2022
  }
1456
2023
  case 'update': {
1457
- const items = step.updatedItems ?? [];
2024
+ const items = step.diff ?? [];
1458
2025
  // 优先按 changeRecords 局部 patch:仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
1459
2026
  // 没有 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);
2027
+ const configs = items
2028
+ .filter((item) => item.oldSchema && item.newSchema)
2029
+ .map(({ oldSchema, newSchema, changeRecords }) => {
2030
+ const oldNode = oldSchema!;
2031
+ const newNode = newSchema!;
2032
+ if (changeRecords?.length) {
2033
+ const sourceForValues = reverse ? oldNode : newNode;
2034
+ // 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
2035
+ // 后续 update -> mergeWith 会与现有节点深合并,patch 中未涉及的字段不会被改动。
2036
+ const patch: MNode = { id: newNode.id, type: newNode.type };
2037
+ for (const record of changeRecords) {
2038
+ if (!record.propPath) {
2039
+ // 没有 propPath 视为整节点替换
2040
+ return cloneDeep(sourceForValues);
2041
+ }
2042
+ const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
2043
+ setValueByKeyPath(record.propPath, value, patch);
1470
2044
  }
1471
- const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
1472
- setValueByKeyPath(record.propPath, value, patch);
2045
+ return patch;
1473
2046
  }
1474
- return patch;
1475
- }
1476
- return cloneDeep(reverse ? oldNode : newNode);
1477
- });
2047
+ return cloneDeep(reverse ? oldNode : newNode);
2048
+ });
1478
2049
  if (configs.length) {
1479
- await this.update(configs, { doNotPushHistory: true });
2050
+ await this.update(configs, { doNotPushHistory: true, historySource: step.source });
1480
2051
  }
1481
2052
  break;
1482
2053
  }
1483
2054
  }
1484
2055
 
1485
- this.set('modifiedNodeIds', step.modifiedNodeIds);
2056
+ this.set('modifiedNodeIds', step.extra?.modifiedNodeIds ?? new Map());
2057
+
2058
+ // 还原校验错误标记:因 undo/redo 复用同一 step,需按方向取「操作前 / 操作后」快照——
2059
+ // undo(reverse=true) 还原到操作前的错误状态(撤销一个「校验失败」的改动后错误消失),
2060
+ // redo(reverse=false) 还原到操作后的错误状态(重做后错误恢复)。
2061
+ // 浅拷贝一份以隔离历史快照,避免后续 set/deleteInvalidNode 反向污染 step.extra。
2062
+ const invalidToRestore =
2063
+ (reverse ? step.extra?.invalidNodeIdsBefore : step.extra?.invalidNodeIdsAfter) ?? new Map();
2064
+ this.set('invalidNodeIds', new Map(invalidToRestore));
2065
+ this.emit('invalid-node-change', this.get('invalidNodeIds'));
1486
2066
 
1487
2067
  const page = toRaw(this.get('page'));
1488
2068
  if (page) {
1489
- const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
2069
+ const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
1490
2070
  setTimeout(() => {
1491
2071
  if (!selectIds.length) return;
1492
2072
 
@@ -1506,6 +2086,22 @@ class Editor extends BaseService {
1506
2086
  private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
1507
2087
  return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
1508
2088
  }
2089
+
2090
+ /**
2091
+ * 撤销 remove / 重做 add 通过手工 splice 按 step 记录的 parentId / index 精确还原节点(不走 this.add,原因见调用处注释),
2092
+ * 这里补齐与 this.add 等价的 add / change 事件,保证撤销/重做路径的事件行为与正向操作一致。
2093
+ */
2094
+ private emitHistoryInsertEvents(nodes: MNode[], source?: HistoryOpSource) {
2095
+ if (!nodes.length) return;
2096
+ this.emit('add', nodes);
2097
+ this.emit('change', {
2098
+ type: 'add',
2099
+ data: nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
2100
+ historySource: source,
2101
+ // 撤销/重做本身不再写入历史记录
2102
+ doNotPushHistory: true,
2103
+ });
2104
+ }
1509
2105
  }
1510
2106
 
1511
2107
  export type EditorService = Editor;