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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/es/Editor.vue_vue_type_script_setup_true_lang.js +11 -2
  2. package/dist/es/components/CodeBlockEditor.vue_vue_type_script_setup_true_lang.js +18 -110
  3. package/dist/es/components/CodeParams.vue_vue_type_script_setup_true_lang.js +6 -0
  4. package/dist/es/components/CompareForm.js +5 -0
  5. package/dist/es/components/CompareForm.vue_vue_type_script_setup_true_lang.js +167 -0
  6. package/dist/es/fields/Code.vue_vue_type_script_setup_true_lang.js +19 -3
  7. package/dist/es/fields/CodeSelect.vue_vue_type_script_setup_true_lang.js +18 -2
  8. package/dist/es/fields/CodeSelectCol.vue_vue_type_script_setup_true_lang.js +37 -6
  9. package/dist/es/fields/DataSourceFieldSelect/FieldSelect.vue_vue_type_script_setup_true_lang.js +3 -1
  10. package/dist/es/fields/DataSourceFieldSelect/Index.vue_vue_type_script_setup_true_lang.js +12 -3
  11. package/dist/es/fields/DataSourceFields.vue_vue_type_script_setup_true_lang.js +14 -6
  12. package/dist/es/fields/DataSourceMethodSelect.vue_vue_type_script_setup_true_name_true_lang.js +3 -1
  13. package/dist/es/fields/DataSourceMethods.vue_vue_type_script_setup_true_lang.js +15 -7
  14. package/dist/es/fields/DataSourceMocks.vue_vue_type_script_setup_true_lang.js +14 -6
  15. package/dist/es/fields/DataSourceSelect.vue_vue_type_script_setup_true_lang.js +3 -1
  16. package/dist/es/fields/EventSelect.vue_vue_type_script_setup_true_lang.js +58 -11
  17. package/dist/es/fields/KeyValue.vue_vue_type_script_setup_true_lang.js +11 -6
  18. package/dist/es/fields/StyleSetter/Index.vue_vue_type_script_setup_true_lang.js +8 -2
  19. package/dist/es/fields/StyleSetter/components/Border.vue_vue_type_script_setup_true_lang.js +10 -2
  20. package/dist/es/fields/StyleSetter/components/Box.vue_vue_type_script_setup_true_lang.js +3 -1
  21. package/dist/es/fields/StyleSetter/pro/Background.vue_vue_type_script_setup_true_lang.js +10 -2
  22. package/dist/es/fields/StyleSetter/pro/Border.vue_vue_type_script_setup_true_lang.js +16 -3
  23. package/dist/es/fields/StyleSetter/pro/Font.vue_vue_type_script_setup_true_lang.js +10 -2
  24. package/dist/es/fields/StyleSetter/pro/Layout.vue_vue_type_script_setup_true_lang.js +14 -2
  25. package/dist/es/fields/StyleSetter/pro/Position.vue_vue_type_script_setup_true_lang.js +10 -2
  26. package/dist/es/fields/StyleSetter/pro/Transform.vue_vue_type_script_setup_true_lang.js +10 -2
  27. package/dist/es/fields/UISelect.vue_vue_type_script_setup_true_lang.js +8 -5
  28. package/dist/es/hooks/use-code-block-edit.js +2 -2
  29. package/dist/es/hooks/use-stage.js +15 -7
  30. package/dist/es/index.js +3 -1
  31. package/dist/es/initService.js +6 -6
  32. package/dist/es/layouts/CodeEditor.vue_vue_type_script_setup_true_lang.js +4 -0
  33. package/dist/es/layouts/Framework.vue_vue_type_script_setup_true_lang.js +12 -7
  34. package/dist/es/layouts/NavMenu.vue_vue_type_script_setup_true_lang.js +8 -0
  35. package/dist/es/layouts/history-list/Bucket.js +5 -0
  36. package/dist/es/layouts/history-list/Bucket.vue_vue_type_script_setup_true_lang.js +84 -0
  37. package/dist/es/layouts/history-list/CodeBlockTab.js +5 -0
  38. package/dist/es/layouts/history-list/CodeBlockTab.vue_vue_type_script_setup_true_lang.js +62 -0
  39. package/dist/es/layouts/history-list/DataSourceTab.js +5 -0
  40. package/dist/es/layouts/history-list/DataSourceTab.vue_vue_type_script_setup_true_lang.js +62 -0
  41. package/dist/es/layouts/history-list/GroupRow.js +5 -0
  42. package/dist/es/layouts/history-list/GroupRow.vue_vue_type_script_setup_true_lang.js +180 -0
  43. package/dist/es/layouts/history-list/HistoryDiffDialog.js +5 -0
  44. package/dist/es/layouts/history-list/HistoryDiffDialog.vue_vue_type_script_setup_true_lang.js +195 -0
  45. package/dist/es/layouts/history-list/HistoryListPanel.js +5 -0
  46. package/dist/es/layouts/history-list/HistoryListPanel.vue_vue_type_script_setup_true_lang.js +270 -0
  47. package/dist/es/layouts/history-list/InitialRow.js +5 -0
  48. package/dist/es/layouts/history-list/InitialRow.vue_vue_type_script_setup_true_lang.js +48 -0
  49. package/dist/es/layouts/history-list/PageTab.js +5 -0
  50. package/dist/es/layouts/history-list/PageTab.vue_vue_type_script_setup_true_lang.js +99 -0
  51. package/dist/es/layouts/history-list/composables.js +154 -0
  52. package/dist/es/layouts/sidebar/layer/use-node-status.js +38 -4
  53. package/dist/es/services/BaseService.js +31 -68
  54. package/dist/es/services/codeBlock.js +205 -8
  55. package/dist/es/services/componentList.js +2 -2
  56. package/dist/es/services/dataSource.js +217 -8
  57. package/dist/es/services/dep.js +21 -9
  58. package/dist/es/services/editor.js +350 -160
  59. package/dist/es/services/events.js +2 -2
  60. package/dist/es/services/history.js +407 -12
  61. package/dist/es/services/keybinding.js +2 -2
  62. package/dist/es/services/props.js +8 -8
  63. package/dist/es/services/stageOverlay.js +2 -2
  64. package/dist/es/services/storage.js +2 -2
  65. package/dist/es/services/ui.js +2 -2
  66. package/dist/es/style.css +347 -1
  67. package/dist/es/utils/code-block.js +122 -0
  68. package/dist/es/utils/content-menu.js +4 -5
  69. package/dist/es/utils/dep/worker.js +1 -1
  70. package/dist/es/utils/editor.js +3 -3
  71. package/dist/es/utils/undo-redo.js +20 -0
  72. package/dist/style.css +347 -1
  73. package/dist/tmagic-editor.umd.cjs +3273 -766
  74. package/package.json +7 -7
  75. package/src/Editor.vue +7 -0
  76. package/src/components/CodeBlockEditor.vue +20 -109
  77. package/src/components/CodeParams.vue +6 -0
  78. package/src/components/CompareForm.vue +225 -0
  79. package/src/editorProps.ts +2 -0
  80. package/src/fields/Code.vue +19 -3
  81. package/src/fields/CodeSelect.vue +17 -1
  82. package/src/fields/CodeSelectCol.vue +30 -2
  83. package/src/fields/DataSourceFieldSelect/FieldSelect.vue +7 -1
  84. package/src/fields/DataSourceFieldSelect/Index.vue +9 -2
  85. package/src/fields/DataSourceFields.vue +12 -3
  86. package/src/fields/DataSourceMethodSelect.vue +4 -1
  87. package/src/fields/DataSourceMethods.vue +14 -4
  88. package/src/fields/DataSourceMocks.vue +13 -3
  89. package/src/fields/DataSourceSelect.vue +4 -1
  90. package/src/fields/EventSelect.vue +57 -8
  91. package/src/fields/KeyValue.vue +17 -4
  92. package/src/fields/StyleSetter/Index.vue +6 -0
  93. package/src/fields/StyleSetter/components/Border.vue +15 -1
  94. package/src/fields/StyleSetter/components/Box.vue +2 -0
  95. package/src/fields/StyleSetter/pro/Background.vue +15 -1
  96. package/src/fields/StyleSetter/pro/Border.vue +24 -2
  97. package/src/fields/StyleSetter/pro/Font.vue +15 -1
  98. package/src/fields/StyleSetter/pro/Layout.vue +17 -1
  99. package/src/fields/StyleSetter/pro/Position.vue +15 -1
  100. package/src/fields/StyleSetter/pro/Transform.vue +15 -1
  101. package/src/fields/UISelect.vue +5 -2
  102. package/src/hooks/use-code-block-edit.ts +5 -2
  103. package/src/hooks/use-stage.ts +23 -7
  104. package/src/index.ts +1 -0
  105. package/src/layouts/CodeEditor.vue +15 -0
  106. package/src/layouts/Framework.vue +10 -5
  107. package/src/layouts/NavMenu.vue +9 -0
  108. package/src/layouts/history-list/Bucket.vue +99 -0
  109. package/src/layouts/history-list/CodeBlockTab.vue +61 -0
  110. package/src/layouts/history-list/DataSourceTab.vue +61 -0
  111. package/src/layouts/history-list/GroupRow.vue +207 -0
  112. package/src/layouts/history-list/HistoryDiffDialog.vue +212 -0
  113. package/src/layouts/history-list/HistoryListPanel.vue +271 -0
  114. package/src/layouts/history-list/InitialRow.vue +41 -0
  115. package/src/layouts/history-list/PageTab.vue +94 -0
  116. package/src/layouts/history-list/composables.ts +195 -0
  117. package/src/layouts/sidebar/layer/use-node-status.ts +64 -12
  118. package/src/services/BaseService.ts +35 -86
  119. package/src/services/codeBlock.ts +256 -5
  120. package/src/services/dataSource.ts +257 -6
  121. package/src/services/dep.ts +32 -9
  122. package/src/services/editor.ts +454 -171
  123. package/src/services/history.ts +452 -8
  124. package/src/services/props.ts +2 -2
  125. package/src/theme/history-list-panel.scss +423 -0
  126. package/src/theme/theme.scss +1 -0
  127. package/src/type.ts +179 -6
  128. package/src/utils/code-block.ts +150 -0
  129. package/src/utils/content-menu.ts +8 -5
  130. package/src/utils/dep/worker.ts +5 -3
  131. package/src/utils/editor.ts +4 -1
  132. package/src/utils/index.ts +1 -0
  133. package/src/utils/undo-redo.ts +23 -0
  134. package/types/index.d.ts +2107 -810
  135. package/dist/es/utils/compose.js +0 -44
  136. package/dist/es/utils/editor-history.js +0 -100
  137. package/src/utils/compose.ts +0 -52
  138. package/src/utils/editor-history.ts +0 -138
@@ -23,10 +23,18 @@ import type { Writable } from 'type-fest';
23
23
  import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
24
24
  import { Target, Watcher } from '@tmagic/core';
25
25
  import type { TableColumnConfig } from '@tmagic/form';
26
+ import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
26
27
 
27
28
  import editorService from '@editor/services/editor';
29
+ import historyService from '@editor/services/history';
28
30
  import storageService, { Protocol } from '@editor/services/storage';
29
- import type { AsyncHookPlugin, CodeState } from '@editor/type';
31
+ import type {
32
+ AsyncHookPlugin,
33
+ CodeBlockStepValue,
34
+ CodeState,
35
+ HistoryOpOptions,
36
+ HistoryOpOptionsWithChangeRecords,
37
+ } from '@editor/type';
30
38
  import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
31
39
  import { getEditorConfig } from '@editor/utils/config';
32
40
  import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
@@ -40,6 +48,18 @@ const canUsePluginMethods = {
40
48
 
41
49
  type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
42
50
 
51
+ /**
52
+ * 「回滚」生成的新 step 简短描述。仅 service 层使用。
53
+ */
54
+ const describeRevertCodeBlockStep = (step: CodeBlockStepValue): string => {
55
+ const { oldContent, newContent, changeRecords, id } = step;
56
+ if (oldContent === null && newContent) return `撤回新增 ${newContent.name || newContent.id || id}`;
57
+ if (oldContent && newContent === null) return `还原已删除的 ${oldContent.name || oldContent.id || id}`;
58
+ const name = newContent?.name || oldContent?.name || `${id}`;
59
+ const propPath = changeRecords?.[0]?.propPath;
60
+ return propPath ? `还原 ${name} · ${propPath}` : `还原 ${name}`;
61
+ };
62
+
43
63
  class CodeBlock extends BaseService {
44
64
  private state = reactive<CodeState>({
45
65
  codeDsl: null,
@@ -92,10 +112,17 @@ class CodeBlock extends BaseService {
92
112
  * 设置代码块ID和代码内容到源dsl
93
113
  * @param {Id} id 代码块id
94
114
  * @param {CodeBlockContent} codeConfig 代码块内容配置信息
115
+ * @param options 可选配置
116
+ * @param options.changeRecords form 端 propPath/value 列表,用于历史记录的精细化撤销/重做
117
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
95
118
  * @returns {void}
96
119
  */
97
- public async setCodeDslById(id: Id, codeConfig: Partial<CodeBlockContent>): Promise<void> {
98
- this.setCodeDslByIdSync(id, codeConfig, true);
120
+ public async setCodeDslById(
121
+ id: Id,
122
+ codeConfig: Partial<CodeBlockContent>,
123
+ { changeRecords, doNotPushHistory = false }: HistoryOpOptionsWithChangeRecords = {},
124
+ ): Promise<void> {
125
+ this.setCodeDslByIdSync(id, codeConfig, true, { changeRecords, doNotPushHistory });
99
126
  }
100
127
 
101
128
  /**
@@ -104,9 +131,18 @@ class CodeBlock extends BaseService {
104
131
  * @param {Id} id 代码块id
105
132
  * @param {CodeBlockContent} codeConfig 代码块内容配置信息
106
133
  * @param {boolean} force 是否强制写入,默认true
134
+ * @param options 可选配置
135
+ * @param options.changeRecords form 端 propPath/value 列表,用于历史记录的精细化撤销/重做
136
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
137
+ * @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
107
138
  * @returns {void}
108
139
  */
109
- public setCodeDslByIdSync(id: Id, codeConfig: Partial<CodeBlockContent>, force = true): void {
140
+ public setCodeDslByIdSync(
141
+ id: Id,
142
+ codeConfig: Partial<CodeBlockContent>,
143
+ force = true,
144
+ { changeRecords, doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
145
+ ): void {
110
146
  const codeDsl = this.getCodeDsl();
111
147
 
112
148
  if (!codeDsl) {
@@ -123,6 +159,9 @@ class CodeBlock extends BaseService {
123
159
  }
124
160
  }
125
161
 
162
+ // 历史记录:在写入前快照旧内容,区分新增/更新
163
+ const oldContent: CodeBlockContent | null = codeDsl[id] ? cloneDeep(codeDsl[id]) : null;
164
+
126
165
  const existContent = codeDsl[id] || {};
127
166
 
128
167
  codeDsl[id] = {
@@ -130,6 +169,12 @@ class CodeBlock extends BaseService {
130
169
  ...codeConfigProcessed,
131
170
  };
132
171
 
172
+ const newContent = cloneDeep(codeDsl[id]);
173
+
174
+ if (!doNotPushHistory) {
175
+ historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords, historyDescription });
176
+ }
177
+
133
178
  this.emit('addOrUpdate', id, codeDsl[id]);
134
179
  }
135
180
 
@@ -218,15 +263,27 @@ class CodeBlock extends BaseService {
218
263
  /**
219
264
  * 在dsl数据源中删除指定id的代码块
220
265
  * @param {Id[]} codeIds 需要删除的代码块id数组
266
+ * @param options 可选配置
267
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
221
268
  */
222
- public async deleteCodeDslByIds(codeIds: Id[]): Promise<void> {
269
+ public async deleteCodeDslByIds(
270
+ codeIds: Id[],
271
+ { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {},
272
+ ): Promise<void> {
223
273
  const currentDsl = await this.getCodeDsl();
224
274
 
225
275
  if (!currentDsl) return;
226
276
 
227
277
  codeIds.forEach((id) => {
278
+ // 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
279
+ const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
280
+
228
281
  delete currentDsl[id];
229
282
 
283
+ if (oldContent && !doNotPushHistory) {
284
+ historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription });
285
+ }
286
+
230
287
  this.emit('remove', id);
231
288
  });
232
289
  }
@@ -239,6 +296,87 @@ class CodeBlock extends BaseService {
239
296
  return this.state.paramsColConfig;
240
297
  }
241
298
 
299
+ /**
300
+ * 撤销指定代码块的最近一次变更。
301
+ *
302
+ * 内部走 setCodeDslByIdSync / deleteCodeDslByIds,因此会自动触发 codeBlockService 的
303
+ * `addOrUpdate` / `remove` 事件,由 initService 中的 handler 重新维护 dep target
304
+ * (DepTargetType.CODE_BLOCK 的 add / remove)。所有写回都带 `doNotPushHistory: true`,
305
+ * 确保不会在历史栈里产生新的记录。
306
+ *
307
+ * @param id 代码块 id
308
+ * @returns 撤销的 step;栈不存在或已无可撤销时返回 null
309
+ */
310
+ public async undo(id: Id): Promise<CodeBlockStepValue | null> {
311
+ const step = historyService.undoCodeBlock(id);
312
+ if (!step) return null;
313
+ await this.applyHistoryStep(step, true);
314
+ return step;
315
+ }
316
+
317
+ /**
318
+ * 重做指定代码块的下一次变更。
319
+ * @param id 代码块 id
320
+ * @returns 重做的 step;栈不存在或已无可重做时返回 null
321
+ */
322
+ public async redo(id: Id): Promise<CodeBlockStepValue | null> {
323
+ const step = historyService.redoCodeBlock(id);
324
+ if (!step) return null;
325
+ await this.applyHistoryStep(step, false);
326
+ return step;
327
+ }
328
+
329
+ /** 是否可对指定代码块撤销。 */
330
+ public canUndo(id: Id): boolean {
331
+ return historyService.canUndoCodeBlock(id);
332
+ }
333
+
334
+ /** 是否可对指定代码块重做。 */
335
+ public canRedo(id: Id): boolean {
336
+ return historyService.canRedoCodeBlock(id);
337
+ }
338
+
339
+ /**
340
+ * 跳转指定代码块的历史栈到目标游标。语义同 editor.gotoPageStep。
341
+ *
342
+ * @param id 代码块 id
343
+ * @param targetCursor 目标游标位置(已应用步骤数量)
344
+ * @returns 实际移动到的最终游标位置
345
+ */
346
+ public async goto(id: Id, targetCursor: number): Promise<number> {
347
+ let cursor = historyService.getCodeBlockCursor(id);
348
+ const target = Math.max(0, targetCursor);
349
+ while (cursor > target) {
350
+ const step = await this.undo(id);
351
+ if (!step) break;
352
+ cursor -= 1;
353
+ }
354
+ while (cursor < target) {
355
+ const step = await this.redo(id);
356
+ if (!step) break;
357
+ cursor += 1;
358
+ }
359
+ return cursor;
360
+ }
361
+
362
+ /**
363
+ * 「回滚」指定代码块历史步骤(类 git revert 语义):
364
+ * - 不动原始栈结构(不移动 cursor、不丢弃任何步骤);
365
+ * - 把目标 step 的修改**反向应用**一次,并作为**新步骤**追加到栈顶;
366
+ * - 仅对已应用的步骤生效。
367
+ *
368
+ * @param id 代码块 id
369
+ * @param index 目标 step 在该栈中的索引(0 为最早),通常由历史面板传入
370
+ * @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
371
+ */
372
+ public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
373
+ const list = historyService.getCodeBlockStepList(id);
374
+ const entry = list[index];
375
+ if (!entry?.applied) return null;
376
+ const description = `回滚 #${index + 1}: ${describeRevertCodeBlockStep(entry.step)}`;
377
+ return await this.applyRevertStep(entry.step, description);
378
+ }
379
+
242
380
  /**
243
381
  * 生成代码块唯一id
244
382
  * @returns {Id} 代码块唯一id
@@ -320,6 +458,119 @@ class CodeBlock extends BaseService {
320
458
  public usePlugin(options: AsyncHookPlugin<AsyncMethodName, CodeBlock>): void {
321
459
  super.usePlugin(options);
322
460
  }
461
+
462
+ /**
463
+ * 反向应用一个 step 并以新 step 入栈。逻辑与 applyHistoryStep(reverse=true) 同构,
464
+ * 差异仅在于通过公开的 setCodeDslByIdSync / deleteCodeDslByIds 触发 push。
465
+ */
466
+ private async applyRevertStep(
467
+ step: CodeBlockStepValue,
468
+ historyDescription: string,
469
+ ): Promise<CodeBlockStepValue | null> {
470
+ const { id, oldContent, newContent, changeRecords } = step;
471
+
472
+ // 原本是新增 → revert 即删除
473
+ if (oldContent === null && newContent) {
474
+ await this.deleteCodeDslByIds([id], { historyDescription });
475
+ return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
476
+ }
477
+
478
+ // 原本是删除 → revert 即写回
479
+ if (oldContent && newContent === null) {
480
+ this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
481
+ return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
482
+ }
483
+
484
+ if (!oldContent || !newContent) return null;
485
+
486
+ // 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
487
+ if (changeRecords?.length) {
488
+ const current = this.getCodeContentById(id);
489
+ if (!current) return null;
490
+ const patched = cloneDeep(current) as CodeBlockContent;
491
+ let fallbackToFullReplace = false;
492
+ for (const record of changeRecords) {
493
+ if (!record.propPath) {
494
+ fallbackToFullReplace = true;
495
+ break;
496
+ }
497
+ const value = cloneDeep(getValueByKeyPath(record.propPath, oldContent));
498
+ setValueByKeyPath(record.propPath, value, patched);
499
+ }
500
+ this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, {
501
+ changeRecords,
502
+ historyDescription,
503
+ });
504
+ return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
505
+ }
506
+
507
+ this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
508
+ return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
509
+ }
510
+
511
+ /**
512
+ * 把一条历史 step 应用到当前代码块服务上。
513
+ *
514
+ * 复用现有的 setCodeDslByIdSync / deleteCodeDslByIds,目的是借助它们发出的事件
515
+ * 触发 initService 中的 dep target 维护(CODE_BLOCK 的 add / remove)。
516
+ * 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。
517
+ *
518
+ * - oldContent=null, newContent≠null:原始为新增 → undo 删除;redo 再次 setCodeDslByIdSync
519
+ * - oldContent≠null, newContent=null:原始为删除 → undo 还原写入;redo 再次删除
520
+ * - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整内容替换
521
+ *
522
+ * @param step 历史 step
523
+ * @param reverse true=撤销,false=重做
524
+ */
525
+ private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
526
+ const { id, oldContent, newContent, changeRecords } = step;
527
+
528
+ // 新增 / 删除:直接 set 或 delete,不走 patch 逻辑
529
+ if (oldContent === null && newContent) {
530
+ if (reverse) {
531
+ await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
532
+ } else {
533
+ this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true });
534
+ }
535
+ return;
536
+ }
537
+
538
+ if (oldContent && newContent === null) {
539
+ if (reverse) {
540
+ this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true });
541
+ } else {
542
+ await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
543
+ }
544
+ return;
545
+ }
546
+
547
+ if (!oldContent || !newContent) return;
548
+
549
+ // 更新场景:优先按 changeRecords 局部 patch;缺省退化为整内容替换
550
+ const sourceForValues = reverse ? oldContent : newContent;
551
+
552
+ if (changeRecords?.length) {
553
+ const current = this.getCodeContentById(id);
554
+ if (!current) return;
555
+ const patched = cloneDeep(current) as CodeBlockContent;
556
+ let fallbackToFullReplace = false;
557
+ for (const record of changeRecords) {
558
+ if (!record.propPath) {
559
+ fallbackToFullReplace = true;
560
+ break;
561
+ }
562
+ const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
563
+ setValueByKeyPath(record.propPath, value, patched);
564
+ }
565
+ this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, true, {
566
+ changeRecords,
567
+ doNotPushHistory: true,
568
+ });
569
+ return;
570
+ }
571
+
572
+ this.setCodeDslByIdSync(id, cloneDeep(sourceForValues), true, { doNotPushHistory: true });
573
+ }
323
574
  }
324
575
 
325
576
  export type CodeBlockService = CodeBlock;
@@ -4,12 +4,19 @@ import type { Writable } from 'type-fest';
4
4
 
5
5
  import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
6
6
  import { Target, Watcher } from '@tmagic/core';
7
- import type { ChangeRecord, FormConfig } from '@tmagic/form';
8
- import { guid, toLine } from '@tmagic/utils';
7
+ import type { FormConfig } from '@tmagic/form';
8
+ import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
9
9
 
10
10
  import editorService from '@editor/services/editor';
11
+ import historyService from '@editor/services/history';
11
12
  import storageService, { Protocol } from '@editor/services/storage';
12
- import type { DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
13
+ import type {
14
+ DataSourceStepValue,
15
+ DatasourceTypeOption,
16
+ HistoryOpOptions,
17
+ HistoryOpOptionsWithChangeRecords,
18
+ SyncHookPlugin,
19
+ } from '@editor/type';
13
20
  import { getFormConfig, getFormValue } from '@editor/utils/data-source';
14
21
  import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
15
22
 
@@ -47,6 +54,19 @@ const canUsePluginMethods = {
47
54
 
48
55
  type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
49
56
 
57
+ /**
58
+ * 「回滚」生成的新 step 简短描述。
59
+ * 仅在 service 层使用,避免依赖 UI 层 composables。
60
+ */
61
+ const describeRevertDataSourceStep = (step: DataSourceStepValue): string => {
62
+ const { oldSchema, newSchema, changeRecords, id } = step;
63
+ if (oldSchema === null && newSchema) return `撤回新增 ${newSchema.title || newSchema.id || id}`;
64
+ if (oldSchema && newSchema === null) return `还原已删除的 ${oldSchema.title || oldSchema.id || id}`;
65
+ const title = newSchema?.title || oldSchema?.title || `${id}`;
66
+ const propPath = changeRecords?.[0]?.propPath;
67
+ return propPath ? `还原 ${title} · ${propPath}` : `还原 ${title}`;
68
+ };
69
+
50
70
  class DataSource extends BaseService {
51
71
  private state = reactive<State>({
52
72
  datasourceTypeList: [],
@@ -102,7 +122,14 @@ class DataSource extends BaseService {
102
122
  this.get('methods')[toLine(type)] = value;
103
123
  }
104
124
 
105
- public add(config: DataSourceSchema) {
125
+ /**
126
+ * 新增数据源
127
+ * @param config 数据源配置
128
+ * @param options 可选配置
129
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
130
+ * @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
131
+ */
132
+ public add(config: DataSourceSchema, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) {
106
133
  const newConfig = {
107
134
  ...config,
108
135
  id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
@@ -110,12 +137,27 @@ class DataSource extends BaseService {
110
137
 
111
138
  this.get('dataSources').push(newConfig);
112
139
 
140
+ if (!doNotPushHistory) {
141
+ historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig, historyDescription });
142
+ }
143
+
113
144
  this.emit('add', newConfig);
114
145
 
115
146
  return newConfig;
116
147
  }
117
148
 
118
- public update(config: DataSourceSchema, { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {}) {
149
+ /**
150
+ * 更新数据源
151
+ * @param config 数据源配置
152
+ * @param data 额外数据
153
+ * @param data.changeRecords form 端变更记录
154
+ * @param data.doNotPushHistory 是否不写入历史记录(默认 false)
155
+ * @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
156
+ */
157
+ public update(
158
+ config: DataSourceSchema,
159
+ { changeRecords = [], doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
160
+ ) {
119
161
  const dataSources = this.get('dataSources');
120
162
 
121
163
  const index = dataSources.findIndex((ds) => ds.id === config.id);
@@ -125,6 +167,15 @@ class DataSource extends BaseService {
125
167
 
126
168
  dataSources[index] = newConfig;
127
169
 
170
+ if (!doNotPushHistory) {
171
+ historyService.pushDataSource(newConfig.id, {
172
+ oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
173
+ newSchema: newConfig,
174
+ changeRecords,
175
+ historyDescription,
176
+ });
177
+ }
178
+
128
179
  this.emit('update', newConfig, {
129
180
  oldConfig,
130
181
  changeRecords,
@@ -133,14 +184,104 @@ class DataSource extends BaseService {
133
184
  return newConfig;
134
185
  }
135
186
 
136
- public remove(id: string) {
187
+ /**
188
+ * 删除数据源
189
+ * @param id 数据源 id
190
+ * @param options 可选配置
191
+ * @param options.doNotPushHistory 是否不写入历史记录(默认 false)
192
+ * @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
193
+ */
194
+ public remove(id: string, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) {
137
195
  const dataSources = this.get('dataSources');
138
196
  const index = dataSources.findIndex((ds) => ds.id === id);
197
+ const oldConfig = index !== -1 ? dataSources[index] : null;
139
198
  dataSources.splice(index, 1);
140
199
 
200
+ if (oldConfig && !doNotPushHistory) {
201
+ historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null, historyDescription });
202
+ }
203
+
141
204
  this.emit('remove', id);
142
205
  }
143
206
 
207
+ /**
208
+ * 撤销指定数据源的最近一次变更。
209
+ *
210
+ * 内部走 add / update / remove,因此会自动触发 dataSourceService 的事件,
211
+ * 由 initService 中的对应 handler 重新收集 dep(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。
212
+ * 所有写回都带 `doNotPushHistory: true`,避免在历史栈里产生新的记录。
213
+ *
214
+ * @param id 数据源 id
215
+ * @returns 撤销的 step;栈不存在或已无可撤销时返回 null
216
+ */
217
+ public undo(id: Id) {
218
+ const step = historyService.undoDataSource(id);
219
+ if (!step) return null;
220
+ this.applyHistoryStep(step, true);
221
+ return step;
222
+ }
223
+
224
+ /**
225
+ * 重做指定数据源的下一次变更。
226
+ * @param id 数据源 id
227
+ * @returns 重做的 step;栈不存在或已无可重做时返回 null
228
+ */
229
+ public redo(id: Id) {
230
+ const step = historyService.redoDataSource(id);
231
+ if (!step) return null;
232
+ this.applyHistoryStep(step, false);
233
+ return step;
234
+ }
235
+
236
+ /** 是否可对指定数据源撤销。 */
237
+ public canUndo(id: Id): boolean {
238
+ return historyService.canUndoDataSource(id);
239
+ }
240
+
241
+ /** 是否可对指定数据源重做。 */
242
+ public canRedo(id: Id): boolean {
243
+ return historyService.canRedoDataSource(id);
244
+ }
245
+
246
+ /**
247
+ * 跳转指定数据源的历史栈到目标游标。语义同 editor.gotoPageStep。
248
+ *
249
+ * @param id 数据源 id
250
+ * @param targetCursor 目标游标位置(已应用步骤数量)
251
+ * @returns 实际移动到的最终游标位置
252
+ */
253
+ public goto(id: Id, targetCursor: number): number {
254
+ let cursor = historyService.getDataSourceCursor(id);
255
+ const target = Math.max(0, targetCursor);
256
+ while (cursor > target) {
257
+ if (!this.undo(id)) break;
258
+ cursor -= 1;
259
+ }
260
+ while (cursor < target) {
261
+ if (!this.redo(id)) break;
262
+ cursor += 1;
263
+ }
264
+ return cursor;
265
+ }
266
+
267
+ /**
268
+ * 「回滚」指定数据源历史步骤(类 git revert 语义):
269
+ * - 不动原始栈结构(不移动 cursor、不丢弃任何步骤);
270
+ * - 把目标 step 的修改**反向应用**一次,并作为**新步骤**追加到栈顶;
271
+ * - 仅对已应用的步骤生效——未应用步骤本身就不存在于当前 schema 中,反向无意义。
272
+ *
273
+ * @param id 数据源 id
274
+ * @param index 目标 step 在该栈中的索引(0 为最早),通常由历史面板传入
275
+ * @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
276
+ */
277
+ public revert(id: Id, index: number): DataSourceStepValue | null {
278
+ const list = historyService.getDataSourceStepList(id);
279
+ const entry = list[index];
280
+ if (!entry?.applied) return null;
281
+ const description = `回滚 #${index + 1}: ${describeRevertDataSourceStep(entry.step)}`;
282
+ return this.applyRevertStep(entry.step, description);
283
+ }
284
+
144
285
  public createId(): string {
145
286
  return `ds_${guid()}`;
146
287
  }
@@ -215,6 +356,116 @@ class DataSource extends BaseService {
215
356
  }
216
357
  });
217
358
  }
359
+
360
+ /**
361
+ * 反向应用一个 step 并以新 step 入栈(不带 doNotPushHistory)。逻辑与 applyHistoryStep(reverse=true)
362
+ * 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
363
+ */
364
+ private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
365
+ const { id, oldSchema, newSchema, changeRecords } = step;
366
+
367
+ // 原本是新增 → revert 即删除
368
+ if (oldSchema === null && newSchema) {
369
+ this.remove(`${id}`, { historyDescription });
370
+ return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
371
+ }
372
+
373
+ // 原本是删除 → revert 即重新加回
374
+ if (oldSchema && newSchema === null) {
375
+ this.add(cloneDeep(oldSchema), { historyDescription });
376
+ return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
377
+ }
378
+
379
+ if (!oldSchema || !newSchema) return null;
380
+
381
+ // 原本是更新 → 把 oldSchema 写回;优先按 changeRecords 局部 patch
382
+ if (changeRecords?.length) {
383
+ const current = this.getDataSourceById(`${id}`);
384
+ if (!current) return null;
385
+ const patched = cloneDeep(current) as DataSourceSchema;
386
+ let fallbackToFullReplace = false;
387
+ for (const record of changeRecords) {
388
+ if (!record.propPath) {
389
+ fallbackToFullReplace = true;
390
+ break;
391
+ }
392
+ const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
393
+ setValueByKeyPath(record.propPath, value, patched);
394
+ }
395
+ this.update(fallbackToFullReplace ? cloneDeep(oldSchema) : patched, {
396
+ changeRecords,
397
+ historyDescription,
398
+ });
399
+ return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
400
+ }
401
+
402
+ this.update(cloneDeep(oldSchema), { historyDescription });
403
+ return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
404
+ }
405
+
406
+ /**
407
+ * 把一条历史 step 应用到当前数据源服务上。
408
+ *
409
+ * 复用现有的 add / update / remove,目的是借助它们发出的事件触发 initService 中
410
+ * 的依赖收集逻辑(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。
411
+ * 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。
412
+ *
413
+ * - oldSchema=null, newSchema≠null:原始为新增 → undo 删除;redo 再次 add
414
+ * - oldSchema≠null, newSchema=null:原始为删除 → undo 还原 add;redo 再次删除
415
+ * - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整 schema 替换
416
+ *
417
+ * @param step 历史 step
418
+ * @param reverse true=撤销,false=重做
419
+ */
420
+ private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
421
+ const { id, oldSchema, newSchema, changeRecords } = step;
422
+
423
+ // 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
424
+ if (oldSchema === null && newSchema) {
425
+ if (reverse) {
426
+ this.remove(`${id}`, { doNotPushHistory: true });
427
+ } else {
428
+ this.add(cloneDeep(newSchema), { doNotPushHistory: true });
429
+ }
430
+ return;
431
+ }
432
+
433
+ if (oldSchema && newSchema === null) {
434
+ if (reverse) {
435
+ this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
436
+ } else {
437
+ this.remove(`${id}`, { doNotPushHistory: true });
438
+ }
439
+ return;
440
+ }
441
+
442
+ if (!oldSchema || !newSchema) return;
443
+
444
+ // 更新场景:优先按 changeRecords 局部 patch;缺省退化为整 schema 替换
445
+ const sourceForValues = reverse ? oldSchema : newSchema;
446
+
447
+ if (changeRecords?.length) {
448
+ const current = this.getDataSourceById(`${id}`);
449
+ if (!current) return;
450
+ const patched = cloneDeep(current) as DataSourceSchema;
451
+ let fallbackToFullReplace = false;
452
+ for (const record of changeRecords) {
453
+ if (!record.propPath) {
454
+ fallbackToFullReplace = true;
455
+ break;
456
+ }
457
+ const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
458
+ setValueByKeyPath(record.propPath, value, patched);
459
+ }
460
+ this.update(fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, {
461
+ changeRecords,
462
+ doNotPushHistory: true,
463
+ });
464
+ return;
465
+ }
466
+
467
+ this.update(cloneDeep(sourceForValues), { doNotPushHistory: true });
468
+ }
218
469
  }
219
470
 
220
471
  export type DataSourceService = DataSource;