@tmagic/editor 1.8.0-beta.3 → 1.8.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/es/components/CompareForm.vue_vue_type_script_setup_true_lang.js +15 -1
  2. package/dist/es/components/ToolButton.vue_vue_type_script_setup_true_lang.js +6 -6
  3. package/dist/es/hooks/use-code-block-edit.js +6 -3
  4. package/dist/es/hooks/use-data-source-edit.js +5 -2
  5. package/dist/es/hooks/use-stage.js +6 -3
  6. package/dist/es/index.js +6 -3
  7. package/dist/es/initService.js +1 -1
  8. package/dist/es/layouts/Framework.vue_vue_type_script_setup_true_lang.js +1 -1
  9. package/dist/es/layouts/NavMenu.vue_vue_type_script_setup_true_lang.js +1 -1
  10. package/dist/es/layouts/history-list/Bucket.vue_vue_type_script_setup_true_lang.js +19 -51
  11. package/dist/es/layouts/history-list/BucketTab.vue_vue_type_script_setup_true_lang.js +22 -36
  12. package/dist/es/layouts/history-list/GroupRow.vue_vue_type_script_setup_true_lang.js +152 -93
  13. package/dist/es/layouts/history-list/HistoryDiffDialog.vue_vue_type_script_setup_true_lang.js +36 -10
  14. package/dist/es/layouts/history-list/HistoryListPanel.vue_vue_type_script_setup_true_lang.js +334 -208
  15. package/dist/es/layouts/history-list/InitialRow.vue_vue_type_script_setup_true_lang.js +28 -7
  16. package/dist/es/layouts/history-list/PageTab.vue_vue_type_script_setup_true_lang.js +47 -57
  17. package/dist/es/layouts/history-list/composables.js +118 -25
  18. package/dist/es/layouts/page-bar/PageBar.vue_vue_type_script_setup_true_lang.js +4 -2
  19. package/dist/es/layouts/props-panel/PropsPanel.vue_vue_type_script_setup_true_lang.js +5 -1
  20. package/dist/es/layouts/sidebar/ComponentListPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  21. package/dist/es/layouts/sidebar/Sidebar.vue_vue_type_script_setup_true_lang.js +5 -1
  22. package/dist/es/layouts/sidebar/code-block/CodeBlockList.vue_vue_type_script_setup_true_lang.js +3 -3
  23. package/dist/es/layouts/sidebar/code-block/CodeBlockListPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  24. package/dist/es/layouts/sidebar/code-block/useContentMenu.js +1 -1
  25. package/dist/es/layouts/sidebar/data-source/DataSourceListPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  26. package/dist/es/layouts/sidebar/data-source/useContentMenu.js +1 -1
  27. package/dist/es/layouts/sidebar/layer/LayerMenu.vue_vue_type_script_setup_true_lang.js +5 -5
  28. package/dist/es/layouts/sidebar/layer/LayerNodeTool.vue_vue_type_script_setup_true_lang.js +1 -1
  29. package/dist/es/layouts/workspace/viewer/Stage.vue_vue_type_script_setup_true_lang.js +1 -1
  30. package/dist/es/layouts/workspace/viewer/ViewerMenu.vue_vue_type_script_setup_true_lang.js +8 -8
  31. package/dist/es/services/codeBlock.js +105 -42
  32. package/dist/es/services/dataSource.js +85 -35
  33. package/dist/es/services/editor.js +317 -122
  34. package/dist/es/services/history.js +270 -204
  35. package/dist/es/services/keybinding.js +3 -3
  36. package/dist/es/services/ui.js +3 -0
  37. package/dist/es/style.css +65 -10
  38. package/dist/es/utils/content-menu.js +17 -9
  39. package/dist/es/utils/editor.js +35 -1
  40. package/dist/es/utils/history.js +223 -0
  41. package/dist/es/utils/indexed-db.js +86 -0
  42. package/dist/es/utils/undo-redo.js +60 -1
  43. package/dist/style.css +65 -10
  44. package/dist/tmagic-editor.umd.cjs +2047 -954
  45. package/package.json +7 -7
  46. package/src/components/CompareForm.vue +21 -1
  47. package/src/components/ToolButton.vue +2 -2
  48. package/src/hooks/use-code-block-edit.ts +4 -3
  49. package/src/hooks/use-data-source-edit.ts +2 -2
  50. package/src/hooks/use-stage.ts +3 -3
  51. package/src/index.ts +1 -0
  52. package/src/initService.ts +1 -1
  53. package/src/layouts/Framework.vue +1 -1
  54. package/src/layouts/NavMenu.vue +1 -1
  55. package/src/layouts/history-list/Bucket.vue +34 -67
  56. package/src/layouts/history-list/BucketTab.vue +46 -51
  57. package/src/layouts/history-list/GroupRow.vue +150 -98
  58. package/src/layouts/history-list/HistoryDiffDialog.vue +96 -69
  59. package/src/layouts/history-list/HistoryListPanel.vue +298 -111
  60. package/src/layouts/history-list/InitialRow.vue +25 -9
  61. package/src/layouts/history-list/PageTab.vue +58 -48
  62. package/src/layouts/history-list/composables.ts +210 -29
  63. package/src/layouts/page-bar/PageBar.vue +4 -2
  64. package/src/layouts/props-panel/PropsPanel.vue +5 -1
  65. package/src/layouts/sidebar/ComponentListPanel.vue +9 -5
  66. package/src/layouts/sidebar/Sidebar.vue +6 -1
  67. package/src/layouts/sidebar/code-block/CodeBlockList.vue +5 -5
  68. package/src/layouts/sidebar/code-block/CodeBlockListPanel.vue +1 -1
  69. package/src/layouts/sidebar/code-block/useContentMenu.ts +1 -1
  70. package/src/layouts/sidebar/data-source/DataSourceListPanel.vue +1 -1
  71. package/src/layouts/sidebar/data-source/useContentMenu.ts +1 -1
  72. package/src/layouts/sidebar/layer/LayerMenu.vue +19 -11
  73. package/src/layouts/sidebar/layer/LayerNodeTool.vue +7 -4
  74. package/src/layouts/workspace/viewer/Stage.vue +1 -1
  75. package/src/layouts/workspace/viewer/ViewerMenu.vue +8 -8
  76. package/src/services/codeBlock.ts +129 -35
  77. package/src/services/dataSource.ts +105 -34
  78. package/src/services/editor.ts +470 -157
  79. package/src/services/history.ts +312 -203
  80. package/src/services/keybinding.ts +3 -3
  81. package/src/services/ui.ts +7 -0
  82. package/src/theme/history-list-panel.scss +87 -6
  83. package/src/theme/page-bar.scss +0 -4
  84. package/src/theme/props-panel.scss +3 -3
  85. package/src/type.ts +221 -61
  86. package/src/utils/content-menu.ts +18 -9
  87. package/src/utils/editor.ts +41 -1
  88. package/src/utils/history.ts +298 -0
  89. package/src/utils/index.ts +2 -0
  90. package/src/utils/indexed-db.ts +122 -0
  91. package/src/utils/undo-redo.ts +88 -0
  92. package/types/index.d.ts +871 -308
@@ -1,44 +1,32 @@
1
1
  <template>
2
- <div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div>
3
- <TMagicScrollbar v-else max-height="360px">
4
- <ul class="m-editor-history-list-ul">
5
- <GroupRow
6
- v-for="group in list"
7
- :key="`pg-${group.steps[0]?.index}`"
8
- :group-key="`pg-${group.steps[0]?.index}`"
9
- :applied="group.applied"
10
- :merged="group.steps.length > 1"
11
- :op-type="group.opType"
12
- :desc="describePageGroup(group)"
13
- :time="formatHistoryTime(groupTimestamp(group))"
14
- :time-title="formatHistoryFullTime(groupTimestamp(group))"
15
- :step-count="group.steps.length"
16
- :sub-steps="
17
- group.steps.map((s) => ({
18
- index: s.index,
19
- applied: s.applied,
20
- isCurrent: s.isCurrent,
21
- desc: describePageStep(s.step),
22
- diffable: isPageStepDiffable(s.step),
23
- revertable: s.applied,
24
- time: formatHistoryTime(s.step.timestamp),
25
- timeTitle: formatHistoryFullTime(s.step.timestamp),
26
- }))
27
- "
28
- :is-current="group.isCurrent"
29
- :expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
30
- @toggle="(key: string) => $emit('toggle', key)"
31
- @goto="(index: number) => $emit('goto', index)"
32
- @diff-step="(index: number) => $emit('diff-step', index)"
33
- @revert-step="(index: number) => $emit('revert-step', index)"
34
- />
35
- <!--
2
+ <div v-if="!list.length && !marker" class="m-editor-history-list-empty">暂无操作记录</div>
3
+ <template v-else>
4
+ <div v-if="list.length" class="m-editor-history-list-toolbar">
5
+ <span class="m-editor-history-list-clear" title="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
6
+ </div>
7
+ <TMagicScrollbar max-height="360px">
8
+ <ul class="m-editor-history-list-ul">
9
+ <GroupRow
10
+ v-for="group in list"
11
+ :key="rowKey(group)"
12
+ :group="toRow(group)"
13
+ :expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
14
+ :select-enabled="true"
15
+ @toggle="(key: string) => $emit('toggle', key)"
16
+ @goto="(index: number) => $emit('goto', index)"
17
+ @diff-step="(index: number) => $emit('diff-step', index)"
18
+ @revert-step="(index: number) => $emit('revert-step', index)"
19
+ @select="(index: number) => $emit('select', index)"
20
+ />
21
+ <!--
36
22
  初始状态项:永远位于列表底部(页面 tab 倒序展示,最底部=最早),
37
23
  作为"未修改"零点。当所有 group 都未 applied 时它即为当前位置。
24
+ 设置 root 时生成的「未修改的初始状态」标记(marker)会作为该行的文案与时间来源。
38
25
  -->
39
- <InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
40
- </ul>
41
- </TMagicScrollbar>
26
+ <InitialRow :is-current="isInitial" :marker="marker" @goto-initial="$emit('goto-initial')" />
27
+ </ul>
28
+ </TMagicScrollbar>
29
+ </template>
42
30
  </template>
43
31
 
44
32
  <script lang="ts" setup>
@@ -46,14 +34,15 @@ import { computed } from 'vue';
46
34
 
47
35
  import { TMagicScrollbar } from '@tmagic/design';
48
36
 
49
- import type { PageHistoryGroup, StepValue } from '@editor/type';
37
+ import type { HistoryRowDescriptor, PageHistoryGroup, StepValue } from '@editor/type';
50
38
 
39
+ import type { HistoryRowGroup } from './composables';
51
40
  import {
52
41
  describePageGroup,
53
42
  describePageStep,
54
- formatHistoryFullTime,
55
- formatHistoryTime,
56
- groupTimestamp,
43
+ isHistoryGroupExpanded,
44
+ isPageStepRevertable,
45
+ toRowGroup,
57
46
  } from './composables';
58
47
  import GroupRow from './GroupRow.vue';
59
48
  import InitialRow from './InitialRow.vue';
@@ -66,11 +55,16 @@ const props = defineProps<{
66
55
  /** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
67
56
  list: PageHistoryGroup[];
68
57
  /**
69
- * 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。
58
+ * 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护。
70
59
  * 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
71
60
  * 这样历史数据更新(新增 / 撤销重做导致列表顺序变化)后,已展开的分组状态仍能正确保持。
72
61
  */
73
62
  expanded: Record<string, boolean>;
63
+ /**
64
+ * 当前活动页的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue)。
65
+ * 提供时即使没有任何操作记录也会展示底部初始行,并用其文案 / 时间渲染。
66
+ */
67
+ marker?: StepValue;
74
68
  }>();
75
69
 
76
70
  defineEmits<{
@@ -84,25 +78,41 @@ defineEmits<{
84
78
  (_e: 'diff-step', _index: number): void;
85
79
  /** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
86
80
  (_e: 'revert-step', _index: number): void;
81
+ /** 用户点击记录行希望选中对应节点,携带目标 step 在栈中的索引。 */
82
+ (_e: 'select', _index: number): void;
83
+ /** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
84
+ (_e: 'clear'): void;
87
85
  }>();
88
86
 
89
87
  /**
90
88
  * 当前 step 是否可查看差异:
91
89
  * - 仅 update 操作;
92
- * - 单节点更新(updatedItems.length === 1),且 oldNode / newNode 都存在。
90
+ * - 单节点更新(diff.length === 1),且 oldSchema / newSchema 都存在。
93
91
  * 多节点更新难以选定单一对比目标,统一不展示差异入口。
94
92
  */
95
93
  const isPageStepDiffable = (step: StepValue): boolean => {
96
94
  if (step.opType !== 'update') return false;
97
- const items = step.updatedItems ?? [];
95
+ const items = step.diff ?? [];
98
96
  if (items.length !== 1) return false;
99
- return Boolean(items[0]?.oldNode && items[0]?.newNode);
97
+ return Boolean(items[0]?.oldSchema && items[0]?.newSchema);
98
+ };
99
+
100
+ /** 页面历史的描述 / 可操作性判定集合,注入给统一的 `toRowGroup`。 */
101
+ const descriptor: HistoryRowDescriptor<StepValue> = {
102
+ describeGroup: describePageGroup,
103
+ describeStep: describePageStep,
104
+ isStepDiffable: isPageStepDiffable,
105
+ isStepRevertable: isPageStepRevertable,
100
106
  };
101
107
 
108
+ const rowKey = (group: PageHistoryGroup) => `pg-${group.steps[0]?.index}`;
109
+
110
+ const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
111
+
102
112
  /**
103
113
  * 是否处于"初始状态"——即对应页面历史栈 cursor===0:
104
- * 当 list 中所有 group 的 applied 都为 false 时即为该状态。
105
- * 没有任何 group 的情况由外层"暂无操作记录"分支兜底,本计算可以不考虑。
114
+ * 当 list 中所有 group 的 applied 都为 false 时即为该状态(空列表 `every` 返回 true,
115
+ * 即仅有 marker、无任何操作记录时也视为处于初始状态)。
106
116
  */
107
- const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied));
117
+ const isInitial = computed(() => props.list.every((g) => !g.applied));
108
118
  </script>
@@ -4,15 +4,85 @@ import { datetimeFormatter } from '@tmagic/form';
4
4
 
5
5
  import { useServices } from '@editor/hooks/use-services';
6
6
  import type {
7
+ BaseStepValue,
7
8
  CodeBlockHistoryGroup,
8
9
  CodeBlockStepValue,
9
10
  DataSourceHistoryGroup,
10
11
  DataSourceStepValue,
12
+ HistoryOpSource,
11
13
  HistoryOpType,
14
+ HistoryRowDescriptor,
12
15
  PageHistoryGroup,
13
16
  StepValue,
14
17
  } from '@editor/type';
15
18
 
19
+ /**
20
+ * 通用 bucket 分组(数据源 / 代码块及业务自定义历史)在面板中的展示结构。
21
+ * 由 Bucket / BucketTab 复用,step 类型通过泛型 T 收窄(约束为 {@link BaseStepValue})。
22
+ */
23
+ export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
24
+ /** 组内最后一步是否已应用 */
25
+ applied: boolean;
26
+ /** 是否为当前所在的分组 */
27
+ isCurrent?: boolean;
28
+ /** 该分组的操作类型 */
29
+ opType: HistoryOpType;
30
+ /** 组内所有步骤 */
31
+ steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
32
+ }
33
+
34
+ /** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step)。 */
35
+ export interface HistoryRowStep {
36
+ /** 该子步在所属栈中的稳定索引。 */
37
+ index: number;
38
+ /** 是否已应用(false 表示已被 undo,UI 灰态)。 */
39
+ applied: boolean;
40
+ /** 是否为当前所在步骤。 */
41
+ isCurrent?: boolean;
42
+ /** 是否为最近一次保存的记录。 */
43
+ saved?: boolean;
44
+ /** 子步描述文案。 */
45
+ desc: string;
46
+ /** 是否可查看差异。 */
47
+ diffable?: boolean;
48
+ /** 是否可回滚。 */
49
+ revertable?: boolean;
50
+ /** 操作途径。 */
51
+ source?: HistoryOpSource;
52
+ /** 时间文案。 */
53
+ time?: string;
54
+ /** 时间的完整 title 提示。 */
55
+ timeTitle?: string;
56
+ }
57
+
58
+ /**
59
+ * GroupRow 渲染所需的整组视图模型(由 {@link toRowGroup} 统一派生)。
60
+ * 把原先 GroupRow 上十多个扁平 props 收敛为单一对象,header 信息与子步列表一并携带。
61
+ */
62
+ export interface HistoryRowGroup {
63
+ /** 分组的稳定 key,作为 toggle 事件 payload 与折叠状态的索引。 */
64
+ key: string;
65
+ /** 组内最后一步是否已应用。 */
66
+ applied: boolean;
67
+ /** 是否为当前所在分组。 */
68
+ isCurrent: boolean;
69
+ /** 操作类型,用于徽标颜色与文案。 */
70
+ opType: HistoryOpType;
71
+ /** 组整体描述文案。 */
72
+ desc: string;
73
+ /** 组的操作途径(取组内最近一步)。 */
74
+ source?: HistoryOpSource;
75
+ /** 组头部时间文案(取组内最近一步)。 */
76
+ time?: string;
77
+ /** 组头部时间的完整 title 提示。 */
78
+ timeTitle?: string;
79
+ /** 子步列表(时间正序);其长度即合并步数,length > 1 即为合并组。 */
80
+ subSteps: HistoryRowStep[];
81
+ }
82
+
83
+ /** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
84
+ export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
85
+
16
86
  /**
17
87
  * 历史记录面板共享逻辑:
18
88
  * - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
@@ -27,10 +97,11 @@ export const useHistoryList = () => {
27
97
  /**
28
98
  * 折叠状态:key 形如 `pg-${组内首步 index}` / `ds-${id}-${组内首步 index}` / `cb-${id}-${组内首步 index}`。
29
99
  * 用组内首步的稳定 index(而非展示位置)作为 key,确保历史数据更新后已展开的分组状态保持不变。
100
+ * 合并组默认展开;仅当值为 `false` 时表示收起。
30
101
  */
31
102
  const expanded = reactive<Record<string, boolean>>({});
32
103
  const toggleGroup = (key: string) => {
33
- expanded[key] = !expanded[key];
104
+ expanded[key] = expanded[key] === false;
34
105
  };
35
106
 
36
107
  const pageGroups = computed(() => historyService.getPageHistoryGroups());
@@ -107,7 +178,80 @@ export const opLabel = (op: HistoryOpType) => {
107
178
  }
108
179
  };
109
180
 
110
- const nameOf = (node: { name?: string; id?: string | number; type?: string }) =>
181
+ /** 内置操作途径的中文文案;自定义来源直接回显原值,未知 / 缺省返回空串(UI 据此不渲染)。 */
182
+ const HISTORY_SOURCE_LABELS: Record<string, string> = {
183
+ stage: '画布',
184
+ tree: '树面板',
185
+ 'component-panel': '组件面板',
186
+ props: '配置面板',
187
+ code: '源码',
188
+ 'root-code': 'DSL源码',
189
+ 'stage-contextmenu': '画布菜单',
190
+ 'tree-contextmenu': '树菜单',
191
+ toolbar: '工具栏',
192
+ shortcut: '快捷键',
193
+ rollback: '回滚',
194
+ api: '接口',
195
+ ai: 'AI',
196
+ initial: '初始值',
197
+ sync: '同步',
198
+ unknown: '未知',
199
+ };
200
+
201
+ /** 操作途径文案:用于历史面板展示「画布 / 树面板 / 配置面板…」标签。 */
202
+ export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
203
+ return HISTORY_SOURCE_LABELS[source] ?? `${source}`;
204
+ };
205
+
206
+ /** 取一组历史步骤里最后一步(最近一次)的操作途径,用于组头部展示。 */
207
+ export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
208
+ group.steps[group.steps.length - 1]?.step.source;
209
+
210
+ /** {@link toRowGroup} 接受的最小分组结构,PageHistoryGroup 与 HistoryBucketGroup 均满足。 */
211
+ interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
212
+ applied: boolean;
213
+ isCurrent?: boolean;
214
+ opType: HistoryOpType;
215
+ steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
216
+ }
217
+
218
+ /**
219
+ * 把一个历史分组(页面 / bucket)派生为 GroupRow 直接消费的视图模型 {@link HistoryRowGroup}。
220
+ * 统一了原先 PageTab / Bucket 各自内联的 sub-steps 映射逻辑:描述、可差异、可回滚、时间、途径
221
+ * 全部在此一次性算好,组件层只负责渲染。
222
+ */
223
+ export const toRowGroup = <T extends BaseStepValue = BaseStepValue>(
224
+ group: RowGroupInput<T>,
225
+ key: string,
226
+ descriptor: HistoryRowDescriptor<T>,
227
+ ): HistoryRowGroup => {
228
+ const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
229
+ const timestamp = groupTimestamp(group);
230
+ return {
231
+ key,
232
+ applied: group.applied,
233
+ isCurrent: Boolean(group.isCurrent),
234
+ opType: group.opType,
235
+ desc: describeGroup(group),
236
+ source: groupSource(group),
237
+ time: formatHistoryTime(timestamp),
238
+ timeTitle: formatHistoryFullTime(timestamp),
239
+ subSteps: group.steps.map((s) => ({
240
+ index: s.index,
241
+ applied: s.applied,
242
+ isCurrent: s.isCurrent,
243
+ saved: s.step.saved,
244
+ desc: describeStep(s.step),
245
+ diffable: isStepDiffable ? isStepDiffable(s.step) : false,
246
+ revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true),
247
+ source: s.step.source,
248
+ time: formatHistoryTime(s.step.timestamp),
249
+ timeTitle: formatHistoryFullTime(s.step.timestamp),
250
+ })),
251
+ };
252
+ };
253
+
254
+ const nameOf = (node?: { name?: string; id?: string | number; type?: string }) =>
111
255
  node?.name || node?.type || `${node?.id ?? ''}`;
112
256
 
113
257
  /**
@@ -132,25 +276,25 @@ const pickLastDescription = (descs: (string | undefined)[]): string | undefined
132
276
  export const describePageStep = (step: StepValue) => {
133
277
  if (step.historyDescription) return step.historyDescription;
134
278
  const { opType } = step;
279
+ const items = step.diff ?? [];
135
280
  if (opType === 'add') {
136
- const count = step.nodes?.length ?? 0;
137
- const node = step.nodes?.[0];
281
+ const count = items.length;
282
+ const node = items[0]?.newSchema;
138
283
  return `新增 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
139
284
  }
140
285
  if (opType === 'remove') {
141
- const count = step.removedItems?.length ?? 0;
142
- const node = step.removedItems?.[0]?.node;
286
+ const count = items.length;
287
+ const node = items[0]?.oldSchema;
143
288
  return `删除 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
144
289
  }
145
- const updated = step.updatedItems ?? [];
146
- if (!updated.length) return '修改节点';
147
- if (updated.length === 1) {
148
- const { newNode, changeRecords } = updated[0];
290
+ if (!items.length) return '修改节点';
291
+ if (items.length === 1) {
292
+ const { newSchema, changeRecords } = items[0];
149
293
  const propPath = changeRecords?.[0]?.propPath;
150
- const target = labelWithId(nameOf(newNode), newNode?.id);
294
+ const target = labelWithId(nameOf(newSchema), newSchema?.id);
151
295
  return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
152
296
  }
153
- return `修改 ${updated.length} 个节点`;
297
+ return `修改 ${items.length} 个节点`;
154
298
  };
155
299
 
156
300
  /**
@@ -165,7 +309,7 @@ export const describePageGroup = (group: PageHistoryGroup) => {
165
309
  if (group.steps.length === 1) return describePageStep(group.steps[0].step);
166
310
  const paths = new Set<string>();
167
311
  group.steps.forEach((s) => {
168
- s.step.updatedItems?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
312
+ s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
169
313
  });
170
314
  const pathList = Array.from(paths).slice(0, 3).join(', ');
171
315
  const target = labelWithId(
@@ -177,12 +321,11 @@ export const describePageGroup = (group: PageHistoryGroup) => {
177
321
 
178
322
  export const describeDataSourceStep = (step: DataSourceStepValue) => {
179
323
  if (step.historyDescription) return step.historyDescription;
180
- if (step.oldSchema === null && step.newSchema)
181
- return `创建 ${labelWithId(step.newSchema.title, step.newSchema.id ?? step.id)}`;
182
- if (step.newSchema === null && step.oldSchema)
183
- return `删除 ${labelWithId(step.oldSchema.title, step.oldSchema.id ?? step.id)}`;
184
- const propPath = step.changeRecords?.[0]?.propPath;
185
- const title = labelWithId(step.newSchema?.title || step.oldSchema?.title, step.id);
324
+ const { oldSchema: oldSchema, newSchema: newSchema, changeRecords } = step.diff?.[0] ?? {};
325
+ if (!oldSchema && newSchema) return `创建 ${labelWithId(newSchema.title, newSchema.id ?? step.id)}`;
326
+ if (!newSchema && oldSchema) return `删除 ${labelWithId(oldSchema.title, oldSchema.id ?? step.id)}`;
327
+ const propPath = changeRecords?.[0]?.propPath;
328
+ const title = labelWithId(newSchema?.title || oldSchema?.title, step.id);
186
329
  return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
187
330
  };
188
331
 
@@ -192,22 +335,23 @@ export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
192
335
  if (group.steps.length === 1) return describeDataSourceStep(group.steps[0].step);
193
336
  const paths = new Set<string>();
194
337
  group.steps.forEach((s) => {
195
- s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
338
+ s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
196
339
  });
197
340
  const pathList = Array.from(paths).slice(0, 3).join(', ');
198
- const rawTitle = group.steps[group.steps.length - 1].step.newSchema?.title || group.steps[0].step.oldSchema?.title;
341
+ const rawTitle =
342
+ group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.title ||
343
+ group.steps[0].step.diff?.[0]?.oldSchema?.title;
199
344
  const target = labelWithId(rawTitle, group.id);
200
345
  return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
201
346
  };
202
347
 
203
348
  export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
204
349
  if (step.historyDescription) return step.historyDescription;
205
- if (step.oldContent === null && step.newContent)
206
- return `创建 ${labelWithId(step.newContent.name, step.newContent.id ?? step.id)}`;
207
- if (step.newContent === null && step.oldContent)
208
- return `删除 ${labelWithId(step.oldContent.name, step.oldContent.id ?? step.id)}`;
209
- const propPath = step.changeRecords?.[0]?.propPath;
210
- const title = labelWithId(step.newContent?.name || step.oldContent?.name, step.id);
350
+ const { oldSchema: oldContent, newSchema: newContent, changeRecords } = step.diff?.[0] ?? {};
351
+ if (!oldContent && newContent) return `创建 ${labelWithId(newContent.name, newContent.id ?? step.id)}`;
352
+ if (!newContent && oldContent) return `删除 ${labelWithId(oldContent.name, oldContent.id ?? step.id)}`;
353
+ const propPath = changeRecords?.[0]?.propPath;
354
+ const title = labelWithId(newContent?.name || oldContent?.name, step.id);
211
355
  return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
212
356
  };
213
357
 
@@ -217,10 +361,47 @@ export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
217
361
  if (group.steps.length === 1) return describeCodeBlockStep(group.steps[0].step);
218
362
  const paths = new Set<string>();
219
363
  group.steps.forEach((s) => {
220
- s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
364
+ s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
221
365
  });
222
366
  const pathList = Array.from(paths).slice(0, 3).join(', ');
223
- const rawName = group.steps[group.steps.length - 1].step.newContent?.name || group.steps[0].step.oldContent?.name;
367
+ const rawName =
368
+ group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.name ||
369
+ group.steps[0].step.diff?.[0]?.oldSchema?.name;
224
370
  const target = labelWithId(rawName, group.id);
225
371
  return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
226
372
  };
373
+
374
+ /**
375
+ * 页面 step 是否支持「回滚」(类 git revert):
376
+ * - 新增 / 删除:不依赖 changeRecords,反向应用即删除 / 加回,始终可回滚;
377
+ * - 更新:必须每个被更新节点都带有 changeRecords,才支持按 propPath 局部反向 patch。
378
+ * 缺失 changeRecords 的更新只能整节点替换,会冲掉该节点后续的无关变更,因此不支持回滚。
379
+ */
380
+ export const isPageStepRevertable = (step: StepValue): boolean => {
381
+ if (step.opType !== 'update') return true;
382
+ const items = step.diff ?? [];
383
+ if (!items.length) return false;
384
+ return items.every((item) => Boolean(item.changeRecords?.length));
385
+ };
386
+
387
+ /**
388
+ * 数据源 step 是否支持「回滚」:
389
+ * - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
390
+ * - 更新(前后 schema 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
391
+ */
392
+ export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => {
393
+ const item = step.diff?.[0];
394
+ if (!item?.oldSchema || !item?.newSchema) return true;
395
+ return Boolean(item.changeRecords?.length);
396
+ };
397
+
398
+ /**
399
+ * 代码块 step 是否支持「回滚」:
400
+ * - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
401
+ * - 更新(前后 content 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
402
+ */
403
+ export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => {
404
+ const item = step.diff?.[0];
405
+ if (!item?.oldSchema || !item?.newSchema) return true;
406
+ return Boolean(item.changeRecords?.length);
407
+ };
@@ -51,6 +51,7 @@
51
51
  type: 'button',
52
52
  text: '删除',
53
53
  icon: Delete,
54
+ buttonProps: { type: 'danger' },
54
55
  handler: () => remove(item),
55
56
  }"
56
57
  ></ToolButton>
@@ -72,7 +73,7 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
72
73
  import { CaretBottom, Delete, DocumentCopy } from '@element-plus/icons-vue';
73
74
 
74
75
  import { type Id, type MPage, type MPageFragment, NodeType } from '@tmagic/core';
75
- import { TMagicIcon, TMagicPopover } from '@tmagic/design';
76
+ import { TMagicIcon, tMagicMessageBox, TMagicPopover } from '@tmagic/design';
76
77
 
77
78
  import ToolButton from '@editor/components/ToolButton.vue';
78
79
  import { useServices } from '@editor/hooks/use-services';
@@ -140,7 +141,8 @@ const copy = (node: MPage | MPageFragment) => {
140
141
  });
141
142
  };
142
143
 
143
- const remove = (node: MPage | MPageFragment) => {
144
+ const remove = async (node: MPage | MPageFragment) => {
145
+ await tMagicMessageBox.confirm('确定删除该页面吗?');
144
146
  editorService.remove(node);
145
147
  };
146
148
 
@@ -151,7 +151,11 @@ const submit = async (v: MNode, eventData?: ContainerChangeEventData) => {
151
151
  });
152
152
  }
153
153
 
154
- editorService.update(newValue, { changeRecords: eventData?.changeRecords });
154
+ // 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords);
155
+ // 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。
156
+ const historySource = eventData ? 'props' : 'code';
157
+
158
+ editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource });
155
159
  } catch (e: any) {
156
160
  emit('submit-error', e);
157
161
  }
@@ -94,11 +94,15 @@ let clientX: number;
94
94
  let clientY: number;
95
95
 
96
96
  const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
97
- editorService.add({
98
- name: text,
99
- type,
100
- ...data,
101
- });
97
+ editorService.add(
98
+ {
99
+ name: text,
100
+ type,
101
+ ...data,
102
+ },
103
+ undefined,
104
+ { historySource: 'component-panel' },
105
+ );
102
106
  };
103
107
 
104
108
  const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => {
@@ -239,7 +239,12 @@ const unWatchEditorContentHeight = watch(
239
239
  },
240
240
  );
241
241
 
242
- const activeTabName = ref(props.data?.status);
242
+ const activeTabName = computed<string>({
243
+ get: () => uiService.get('sideBarActiveTabName'),
244
+ set: (value) => uiService.set('sideBarActiveTabName', value),
245
+ });
246
+
247
+ uiService.set('sideBarActiveTabName', props.data?.status || '');
243
248
 
244
249
  const getItemConfig = (data: SideItem): SideComponent => {
245
250
  const map: Record<string, SideComponent> = {
@@ -24,7 +24,7 @@
24
24
  <Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
25
25
  </TMagicTooltip>
26
26
  <TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
27
- <Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`)"></Icon>
27
+ <Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`, { historySource: 'tree' })"></Icon>
28
28
  </TMagicTooltip>
29
29
  <slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
30
30
  </template>
@@ -44,7 +44,7 @@ import Tree from '@editor/components/Tree.vue';
44
44
  import { useFilter } from '@editor/hooks/use-filter';
45
45
  import { useNodeStatus } from '@editor/hooks/use-node-status';
46
46
  import { useServices } from '@editor/hooks/use-services';
47
- import { type CodeBlockListSlots, CodeDeleteErrorType, type TreeNodeData } from '@editor/type';
47
+ import { type CodeBlockListSlots, CodeDeleteErrorType, HistoryOpSource, type TreeNodeData } from '@editor/type';
48
48
 
49
49
  defineSlots<CodeBlockListSlots>();
50
50
 
@@ -60,7 +60,7 @@ const props = defineProps<{
60
60
 
61
61
  const emit = defineEmits<{
62
62
  edit: [id: string];
63
- remove: [id: string];
63
+ remove: [id: string, { historySource?: HistoryOpSource }];
64
64
  'node-contextmenu': [event: MouseEvent, data: TreeNodeData];
65
65
  }>();
66
66
 
@@ -142,7 +142,7 @@ const editCode = (id: string) => {
142
142
  emit('edit', id);
143
143
  };
144
144
 
145
- const deleteCode = async (id: string) => {
145
+ const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
146
146
  const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
147
147
  const existBinds = Boolean(currentCode?.items?.length);
148
148
  const undeleteableList = codeBlockService.getUndeletableList() || [];
@@ -154,7 +154,7 @@ const deleteCode = async (id: string) => {
154
154
  });
155
155
 
156
156
  // 无绑定关系,且不在不可删除列表中
157
- emit('remove', id);
157
+ emit('remove', id, { historySource });
158
158
  } else {
159
159
  if (typeof props.customError === 'function') {
160
160
  props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);
@@ -122,7 +122,7 @@ const {
122
122
  menuData: contentMenuData,
123
123
  contentMenuHideHandler,
124
124
  } = useContentMenu((id: string) => {
125
- codeBlockListRef.value?.deleteCode(id);
125
+ codeBlockListRef.value?.deleteCode(id, { historySource: 'tree-contextmenu' });
126
126
  });
127
127
  const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block'));
128
128
  </script>
@@ -41,7 +41,7 @@ export const useContentMenu = (deleteCode: (id: string) => void) => {
41
41
 
42
42
  const newCodeId = await codeBlockService.getUniqueId();
43
43
 
44
- codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock));
44
+ codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock), { historySource: 'tree-contextmenu' });
45
45
  },
46
46
  },
47
47
  {
@@ -129,7 +129,7 @@ const removeHandler = async (id: string) => {
129
129
  type: 'warning',
130
130
  });
131
131
 
132
- dataSourceService.remove(id);
132
+ dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
133
133
  };
134
134
 
135
135
  const dataSourceListRef = useTemplateRef<InstanceType<typeof DataSourceList>>('dataSourceList');
@@ -39,7 +39,7 @@ export const useContentMenu = () => {
39
39
  return;
40
40
  }
41
41
 
42
- dataSourceService.add(cloneDeep(ds));
42
+ dataSourceService.add(cloneDeep(ds), { historySource: 'tree-contextmenu' });
43
43
  },
44
44
  },
45
45
  {