feat(editor): 历史记录支持操作来源

This commit is contained in:
roymondchen 2026-06-04 16:08:52 +08:00
parent a8a9cf372d
commit 27b2c2c685
42 changed files with 513 additions and 131 deletions

View File

@ -1,5 +1,8 @@
# codeBlockService方法
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock``historyDescription` / `source` 字段。
## setCodeDsl
- **参数:**
@ -51,6 +54,8 @@
- `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
@ -72,6 +77,8 @@
- `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:**
- `{void}`
@ -213,6 +220,8 @@
- `{(string | number)[]}` codeIds 需要删除的代码块id数组
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:**
- `{Promise<void>}`

View File

@ -300,6 +300,8 @@ dataSourceService.setFormMethod("http", [
- {`DataSourceSchema`} config 数据源配置
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:**
- {`DataSourceSchema`} 添加后的数据源配置
@ -338,6 +340,8 @@ console.log(newDs.id); // 自动生成的id
- `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
@ -379,6 +383,8 @@ console.log(updatedDs);
- `{string}` id 数据源id
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:**
- `{void}`

View File

@ -1,5 +1,25 @@
# editorService方法
## 历史记录相关 options
下列 DSL 操作方法([add](#add)、[remove](#remove)、[update](#update) 等)的 `options` / `data` 参数,以及
[codeBlockService](./codeBlockServiceMethods.md) / [dataSourceService](./dataSourceServiceMethods.md)
`options`,在 `doNotPushHistory` 之外还可传入:
- `{string}` **historyDescription**:入栈时附带的人类可读描述,用于历史面板展示;不影响 undo/redo 行为,缺省时面板会自动生成描述
- `{HistoryOpSource}` **historySource**:操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为,缺省时面板视为「未知」
编辑器内置交互(画布、树面板、配置面板、右键菜单、快捷键等)会自动传入对应的 `historySource`
业务侧程序化调用时建议显式传入(如 `api`),便于历史面板区分来源。
::: details 查看 HistoryOpOptions / DslOpOptions / HistoryOpSource 类型定义
<<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts}
<<< @/../packages/editor/src/type.ts#DslOpOptions{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
:::
## get
- **参数:**
@ -359,6 +379,8 @@ editorService.highlight("text_123");
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
@ -405,6 +427,8 @@ editorService.highlight("text_123");
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -459,6 +483,8 @@ editorService.highlight("text_123");
- {`ChangeRecord`[]} changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 `changeRecordList`
- {`ChangeRecord`[][]} changeRecordList 多节点 form 端变更记录列表,按 `config` 数组同序对应每个节点;优先级高于 `changeRecords`
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
@ -500,6 +526,7 @@ editorService.highlight("text_123");
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -568,6 +595,8 @@ editorService.highlight("text_123");
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false跨页粘贴时为 true 会跳过页面切换)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
@ -606,6 +635,8 @@ editorService.highlight("text_123");
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style方法内为空操作保留以与其它 DSL 操作 API 一致)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- {Promise<`MNode` | `MNode`[]>}
@ -628,6 +659,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{number | 'top' | 'bottom'}` offset
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -649,6 +682,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- Promise<`MNode` | undefined>
@ -665,6 +700,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{number}` targetIndex 目标位置索引
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -685,6 +722,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
@ -699,6 +738,16 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **返回:**
- {Promise<`StepValue` | null>}
::: details 查看 StepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#StepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
- **详情:**
恢复到下一步
@ -712,6 +761,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{number}` top
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`

View File

@ -21,6 +21,8 @@
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
<<< @/../packages/schema/src/index.ts#MNode{ts}
@ -39,6 +41,8 @@
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
@ -59,6 +63,8 @@
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::

View File

@ -43,6 +43,8 @@
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
<<< @/../packages/schema/src/index.ts#MNode{ts}
@ -61,6 +63,8 @@
`opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
`StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
:::
## undo
@ -91,10 +95,14 @@
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
@ -172,10 +180,14 @@
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null`
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::

View File

@ -6,7 +6,7 @@ import { tMagicMessage } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
import type { Services } from '@editor/type';
import type { HistoryOpSource, Services } from '@editor/type';
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
@ -58,8 +58,8 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
};
// 删除代码块
const deleteCode = async (key: string) => {
codeBlockService.deleteCodeDslByIds([key]);
const deleteCode = async (key: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
codeBlockService.deleteCodeDslByIds([key], { historySource });
};
const submitCodeBlockHandler = async (values: CodeBlockContent, eventData?: ContainerChangeEventData) => {
@ -67,6 +67,7 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
await codeBlockService.setCodeDslById(codeId.value, values, {
changeRecords: eventData?.changeRecords,
historySource: 'props',
});
codeBlockEditorRef.value?.hide();

View File

@ -27,9 +27,9 @@ export const useDataSourceEdit = (dataSourceService: Services['dataSourceService
const submitDataSourceHandler = (value: DataSourceSchema, eventData: ContainerChangeEventData) => {
if (value.id) {
dataSourceService.update(value, { changeRecords: eventData.changeRecords });
dataSourceService.update(value, { changeRecords: eventData.changeRecords, historySource: 'props' });
} else {
dataSourceService.add(value);
dataSourceService.add(value, { historySource: 'props' });
}
editDialog.value?.hide();

View File

@ -130,16 +130,16 @@ export const useStage = (stageOptions: StageOptions) => {
});
if (configs.length === 0) return;
editorService.update(configs, { changeRecordList });
editorService.update(configs, { changeRecordList, historySource: 'stage' });
});
stage.on('sort', (ev: SortEventData) => {
editorService.sort(ev.src, ev.dist);
editorService.sort(ev.src, ev.dist, { historySource: 'stage' });
});
stage.on('remove', (ev: RemoveEventData) => {
const nodes = ev.data.map(({ el }) => editorService.getNodeById(getIdFromEl()(el) || ''));
editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[]);
editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[], { historySource: 'stage' });
});
stage.on('select-parent', () => {

View File

@ -80,7 +80,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
disabled: () => editorService.get('node')?.type === NodeType.PAGE,
handler: () => {
const node = editorService.get('node');
node && editorService.remove(node);
node && editorService.remove(node, { historySource: 'toolbar' });
},
});
break;

View File

@ -15,6 +15,7 @@
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describeGroup(group)"
:source="groupSource(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
@ -26,6 +27,7 @@
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied,
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
@ -58,7 +60,7 @@ import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import { formatHistoryFullTime, formatHistoryTime, groupTimestamp } from './composables';
import { formatHistoryFullTime, formatHistoryTime, groupSource, groupTimestamp } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';

View File

@ -13,6 +13,13 @@
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span
v-if="sourceLabel(source)"
class="m-editor-history-list-item-source"
:title="`操作途径:${sourceLabel(source)}`"
>{{ sourceLabel(source) }}</span
>
<span v-if="time" class="m-editor-history-list-item-time" :title="timeTitle || time">{{ time }}</span>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} </span>
@ -50,6 +57,12 @@
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
<span
v-if="sourceLabel(s.source)"
class="m-editor-history-list-item-source"
:title="`操作途径:${sourceLabel(s.source)}`"
>{{ sourceLabel(s.source) }}</span
>
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
<span
v-if="s.revertable"
@ -80,9 +93,9 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import type { HistoryOpSource, HistoryOpType } from '@editor/type';
import { opLabel } from './composables';
import { opLabel, sourceLabel } from './composables';
defineOptions({
name: 'MEditorHistoryListGroupRow',
@ -100,6 +113,8 @@ const props = withDefaults(
opType: HistoryOpType;
/** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
desc: string;
/** 组的操作途径(一般取组内最近一步),用于头部展示「画布 / 树面板 / 配置面板…」标签。 */
source?: HistoryOpSource;
/** 组头部展示的时间文案(一般为组内最近一步的时间),为空时不渲染。 */
time?: string;
/** 组头部时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
@ -115,6 +130,8 @@ const props = withDefaults(
diffable?: boolean;
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
revertable?: boolean;
/** 该子步的操作途径,用于展示「画布 / 树面板 / 配置面板…」标签。 */
source?: HistoryOpSource;
/** 该子步的时间文案,为空时不渲染。 */
time?: string;
/** 该子步时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */

View File

@ -10,6 +10,7 @@
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describePageGroup(group)"
:source="groupSource(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
@ -21,6 +22,7 @@
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied,
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
@ -53,6 +55,7 @@ import {
describePageStep,
formatHistoryFullTime,
formatHistoryTime,
groupSource,
groupTimestamp,
} from './composables';
import GroupRow from './GroupRow.vue';

View File

@ -8,6 +8,7 @@ import type {
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
PageHistoryGroup,
StepValue,
@ -107,6 +108,32 @@ export const opLabel = (op: HistoryOpType) => {
}
};
/** 内置操作途径的中文文案;自定义来源直接回显原值,未知 / 缺省返回空串UI 据此不渲染)。 */
const HISTORY_SOURCE_LABELS: Record<string, string> = {
stage: '画布',
tree: '树面板',
'component-panel': '组件面板',
props: '配置面板',
code: '源码',
'stage-contextmenu': '画布菜单',
'tree-contextmenu': '树菜单',
toolbar: '工具栏',
shortcut: '快捷键',
rollback: '回滚',
api: '接口',
ai: 'AI',
unknown: '未知',
};
/** 操作途径文案:用于历史面板展示「画布 / 树面板 / 配置面板…」标签。 */
export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
return HISTORY_SOURCE_LABELS[source] ?? `${source}`;
};
/** 取一组历史步骤里最后一步(最近一次)的操作途径,用于组头部展示。 */
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
group.steps[group.steps.length - 1]?.step.source;
const nameOf = (node: { name?: string; id?: string | number; type?: string }) =>
node?.name || node?.type || `${node?.id ?? ''}`;

View File

@ -151,7 +151,11 @@ const submit = async (v: MNode, eventData?: ContainerChangeEventData) => {
});
}
editorService.update(newValue, { changeRecords: eventData?.changeRecords });
// MForm @change eventData changeRecords
// CodeEditor @save saveCode eventData
const historySource = eventData ? 'props' : 'code';
editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource });
} catch (e: any) {
emit('submit-error', e);
}

View File

@ -94,11 +94,15 @@ let clientX: number;
let clientY: number;
const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
editorService.add({
name: text,
type,
...data,
});
editorService.add(
{
name: text,
type,
...data,
},
undefined,
{ historySource: 'component-panel' },
);
};
const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => {

View File

@ -24,7 +24,7 @@
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
</TMagicTooltip>
<TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`)"></Icon>
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`, { historySource: 'tree' })"></Icon>
</TMagicTooltip>
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
</template>
@ -44,7 +44,7 @@ import Tree from '@editor/components/Tree.vue';
import { useFilter } from '@editor/hooks/use-filter';
import { useNodeStatus } from '@editor/hooks/use-node-status';
import { useServices } from '@editor/hooks/use-services';
import { type CodeBlockListSlots, CodeDeleteErrorType, type TreeNodeData } from '@editor/type';
import { type CodeBlockListSlots, CodeDeleteErrorType, HistoryOpSource, type TreeNodeData } from '@editor/type';
defineSlots<CodeBlockListSlots>();
@ -60,7 +60,7 @@ const props = defineProps<{
const emit = defineEmits<{
edit: [id: string];
remove: [id: string];
remove: [id: string, { historySource?: HistoryOpSource }];
'node-contextmenu': [event: MouseEvent, data: TreeNodeData];
}>();
@ -142,7 +142,7 @@ const editCode = (id: string) => {
emit('edit', id);
};
const deleteCode = async (id: string) => {
const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
const existBinds = Boolean(currentCode?.items?.length);
const undeleteableList = codeBlockService.getUndeletableList() || [];
@ -154,7 +154,7 @@ const deleteCode = async (id: string) => {
});
//
emit('remove', id);
emit('remove', id, { historySource });
} else {
if (typeof props.customError === 'function') {
props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);

View File

@ -122,7 +122,7 @@ const {
menuData: contentMenuData,
contentMenuHideHandler,
} = useContentMenu((id: string) => {
codeBlockListRef.value?.deleteCode(id);
codeBlockListRef.value?.deleteCode(id, { historySource: 'tree-contextmenu' });
});
const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block'));
</script>

View File

@ -41,7 +41,7 @@ export const useContentMenu = (deleteCode: (id: string) => void) => {
const newCodeId = await codeBlockService.getUniqueId();
codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock));
codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock), { historySource: 'tree-contextmenu' });
},
},
{

View File

@ -129,7 +129,7 @@ const removeHandler = async (id: string) => {
type: 'warning',
});
dataSourceService.remove(id);
dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
};
const dataSourceListRef = useTemplateRef<InstanceType<typeof DataSourceList>>('dataSourceList');

View File

@ -39,7 +39,7 @@ export const useContentMenu = () => {
return;
}
dataSourceService.add(cloneDeep(ds));
dataSourceService.add(cloneDeep(ds), { historySource: 'tree-contextmenu' });
},
},
{

View File

@ -41,11 +41,15 @@ const createMenuItems = (group: ComponentGroup): MenuButton[] =>
type: 'button',
icon: component.icon,
handler: () => {
editorService.add({
name: component.text,
type: component.type,
...(component.data || {}),
});
editorService.add(
{
name: component.text,
type: component.type,
...(component.data || {}),
},
undefined,
{ historySource: 'tree-contextmenu' },
);
},
}));
@ -57,9 +61,13 @@ const getSubMenuData = computed<MenuButton[]>(() => {
type: 'button',
icon: Files,
handler: () => {
editorService.add({
type: 'tab-pane',
});
editorService.add(
{
type: 'tab-pane',
},
undefined,
{ historySource: 'tree-contextmenu' },
);
},
},
];
@ -106,9 +114,9 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
items: getSubMenuData.value,
},
useCopyMenu(),
usePasteMenu(),
useDeleteMenu(),
useMoveToMenu(services),
usePasteMenu('tree-contextmenu'),
useDeleteMenu('tree-contextmenu'),
useMoveToMenu(services, 'tree-contextmenu'),
...props.layerContentMenu,
],
'layer',

View File

@ -25,9 +25,12 @@ const props = defineProps<{
const { editorService } = useServices();
const setNodeVisible = (visible: boolean) => {
editorService.update({
id: props.data.id,
visible,
});
editorService.update(
{
id: props.data.id,
visible,
},
{ historySource: 'tree' },
);
};
</script>

View File

@ -380,7 +380,7 @@ const dropHandler = async (e: DragEvent) => {
config.data.inputEvent = e;
editorService.add(config.data, parent);
editorService.add(config.data, parent, { historySource: 'component-panel' });
}
};
</script>

View File

@ -49,11 +49,11 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
display: () => canCenter.value,
handler: () => {
if (!nodes.value) return;
editorService.alignCenter(nodes.value);
editorService.alignCenter(nodes.value, { historySource: 'stage-contextmenu' });
},
},
useCopyMenu(),
usePasteMenu(menuRef),
usePasteMenu('stage-contextmenu', menuRef),
{
type: 'divider',
direction: 'horizontal',
@ -68,7 +68,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Top),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(1);
editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
},
},
{
@ -77,7 +77,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Bottom),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(-1);
editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
},
},
{
@ -86,7 +86,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Top),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(LayerOffset.TOP);
editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
},
},
{
@ -95,16 +95,16 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Bottom),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(LayerOffset.BOTTOM);
editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
},
},
useMoveToMenu(services),
useMoveToMenu(services, 'stage-contextmenu'),
{
type: 'divider',
direction: 'horizontal',
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
},
useDeleteMenu(),
useDeleteMenu('stage-contextmenu'),
{
type: 'divider',
direction: 'horizontal',

View File

@ -120,9 +120,19 @@ class CodeBlock extends BaseService {
public async setCodeDslById(
id: Id,
codeConfig: Partial<CodeBlockContent>,
{ changeRecords, doNotPushHistory = false }: HistoryOpOptionsWithChangeRecords = {},
{
changeRecords,
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
): Promise<void> {
this.setCodeDslByIdSync(id, codeConfig, true, { changeRecords, doNotPushHistory });
this.setCodeDslByIdSync(id, codeConfig, true, {
changeRecords,
doNotPushHistory,
historyDescription,
historySource,
});
}
/**
@ -141,7 +151,12 @@ class CodeBlock extends BaseService {
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
{ changeRecords, doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
{
changeRecords,
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
): void {
const codeDsl = this.getCodeDsl();
@ -172,7 +187,13 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords, historyDescription });
historyService.pushCodeBlock(id, {
oldContent,
newContent,
changeRecords,
historyDescription,
source: historySource,
});
}
this.emit('addOrUpdate', id, codeDsl[id]);
@ -268,7 +289,7 @@ class CodeBlock extends BaseService {
*/
public async deleteCodeDslByIds(
codeIds: Id[],
{ doNotPushHistory = false, historyDescription }: HistoryOpOptions = {},
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
): Promise<void> {
const currentDsl = await this.getCodeDsl();
@ -281,7 +302,7 @@ class CodeBlock extends BaseService {
delete currentDsl[id];
if (oldContent && !doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription });
historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription, source: historySource });
}
this.emit('remove', id);
@ -471,13 +492,13 @@ class CodeBlock extends BaseService {
// 原本是新增 → revert 即删除
if (oldContent === null && newContent) {
await this.deleteCodeDslByIds([id], { historyDescription });
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即写回
if (oldContent && newContent === null) {
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
@ -500,11 +521,12 @@ class CodeBlock extends BaseService {
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, {
changeRecords,
historyDescription,
historySource: 'rollback',
});
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}

View File

@ -129,7 +129,10 @@ class DataSource extends BaseService {
* @param options.doNotPushHistory false
* @param options.historyDescription
*/
public add(config: DataSourceSchema, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) {
public add(
config: DataSourceSchema,
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
) {
const newConfig = {
...config,
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
@ -138,7 +141,12 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig);
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig, historyDescription });
historyService.pushDataSource(newConfig.id, {
oldSchema: null,
newSchema: newConfig,
historyDescription,
source: historySource,
});
}
this.emit('add', newConfig);
@ -156,7 +164,12 @@ class DataSource extends BaseService {
*/
public update(
config: DataSourceSchema,
{ changeRecords = [], doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
{
changeRecords = [],
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
) {
const dataSources = this.get('dataSources');
@ -173,6 +186,7 @@ class DataSource extends BaseService {
newSchema: newConfig,
changeRecords,
historyDescription,
source: historySource,
});
}
@ -191,14 +205,19 @@ class DataSource extends BaseService {
* @param options.doNotPushHistory false
* @param options.historyDescription
*/
public remove(id: string, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) {
public remove(id: string, { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}) {
const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === id);
const oldConfig = index !== -1 ? dataSources[index] : null;
dataSources.splice(index, 1);
if (oldConfig && !doNotPushHistory) {
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null, historyDescription });
historyService.pushDataSource(id, {
oldSchema: cloneDeep(oldConfig),
newSchema: null,
historyDescription,
source: historySource,
});
}
this.emit('remove', id);
@ -366,13 +385,13 @@ class DataSource extends BaseService {
// 原本是新增 → revert 即删除
if (oldSchema === null && newSchema) {
this.remove(`${id}`, { historyDescription });
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即重新加回
if (oldSchema && newSchema === null) {
this.add(cloneDeep(oldSchema), { historyDescription });
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
@ -395,11 +414,12 @@ class DataSource extends BaseService {
this.update(fallbackToFullReplace ? cloneDeep(oldSchema) : patched, {
changeRecords,
historyDescription,
historySource: 'rollback',
});
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
this.update(cloneDeep(oldSchema), { historyDescription });
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}

View File

@ -36,6 +36,7 @@ import type {
DslOpOptions,
EditorEvents,
EditorNodeInfo,
HistoryOpSource,
HistoryOpType,
PastePosition,
StepValue,
@ -406,7 +407,13 @@ class Editor extends BaseService {
public async add(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
@ -466,9 +473,8 @@ class Editor extends BaseService {
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
if (!doNotPushHistory) {
this.pushOpHistory(
'add',
{
this.pushOpHistory('add', {
extra: {
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
@ -478,9 +484,10 @@ class Editor extends BaseService {
}),
),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
);
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -577,7 +584,13 @@ class Editor extends BaseService {
*/
public async remove(
nodeOrNodeList: MNode | MNode[],
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp();
@ -606,7 +619,12 @@ class Editor extends BaseService {
if (removedItems.length > 0 && pageForOp) {
if (!doNotPushHistory) {
this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription);
this.pushOpHistory('remove', {
extra: { removedItems },
pageData: pageForOp,
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -704,11 +722,12 @@ class Editor extends BaseService {
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
} = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription } = data;
const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription, historySource } = data;
const nodes = Array.isArray(config) ? config : [config];
@ -726,9 +745,8 @@ class Editor extends BaseService {
if (curNodes.length) {
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory(
'update',
{
this.pushOpHistory('update', {
extra: {
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
@ -737,9 +755,10 @@ class Editor extends BaseService {
changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
})),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
);
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -763,7 +782,7 @@ class Editor extends BaseService {
public async sort(
id1: Id,
id2: Id,
{ doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
{ doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp();
@ -783,7 +802,7 @@ class Editor extends BaseService {
parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
await this.update(parent, { doNotPushHistory });
await this.update(parent, { doNotPushHistory, historySource });
if (!doNotSelect) {
await this.select(node);
}
@ -836,7 +855,13 @@ class Editor extends BaseService {
public async paste(
position: PastePosition = {},
collectorOptions?: TargetOptions,
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[] | void> {
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return;
@ -857,7 +882,13 @@ class Editor extends BaseService {
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
}
return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage, doNotPushHistory });
return this.add(pasteConfigs, parent, {
doNotSelect,
doNotSwitchPage,
doNotPushHistory,
historyDescription,
historySource,
});
}
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@ -893,14 +924,14 @@ class Editor extends BaseService {
*/
public async alignCenter(
config: MNode | MNode[],
{ doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
{ doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
): Promise<MNode | MNode[]> {
const nodes = Array.isArray(config) ? config : [config];
const stage = this.get('stage');
const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
const newNode = await this.update(newNodes, { doNotPushHistory });
const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource });
if (!doNotSelect) {
if (newNodes.length > 1) {
@ -919,7 +950,10 @@ class Editor extends BaseService {
* @param options
* @param options.doNotPushHistory false
*/
public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise<void> {
public async moveLayer(
offset: number | LayerOffset,
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root');
@ -960,10 +994,15 @@ class Editor extends BaseService {
const pageForOp = this.getNodeInfo(node.id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
extra: {
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
},
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
} else {
this.selectionBeforeOp = null;
@ -989,7 +1028,13 @@ class Editor extends BaseService {
public async moveToContainer(
config: MNode | MNode[],
targetId: Id,
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[]> {
const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
@ -1052,7 +1097,12 @@ class Editor extends BaseService {
newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
}));
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
this.pushOpHistory('update', { updatedItems }, historyPage);
this.pushOpHistory('update', {
extra: { updatedItems },
pageData: historyPage,
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -1064,7 +1114,7 @@ class Editor extends BaseService {
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
{ doNotPushHistory = false }: DslOpOptions = {},
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
) {
this.captureSelectionBeforeOp();
@ -1127,7 +1177,12 @@ class Editor extends BaseService {
}
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
this.pushOpHistory('update', {
extra: { updatedItems },
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -1193,7 +1248,7 @@ class Editor extends BaseService {
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
// revert 走 public add/remove/update让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription } as const;
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
try {
switch (step.opType) {
@ -1241,7 +1296,7 @@ class Editor extends BaseService {
return cloneDeep(oldNode);
});
if (configs.length) {
await this.update(configs, { historyDescription });
await this.update(configs, { historyDescription, historySource: 'rollback' });
}
break;
}
@ -1285,14 +1340,21 @@ class Editor extends BaseService {
return cursor;
}
public async move(left: number, top: number, { doNotPushHistory = false }: DslOpOptions = {}) {
public async move(
left: number,
top: number,
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
) {
const node = toRaw(this.get('node'));
if (!node || isPage(node)) return;
const newStyle = calcMoveStyle(node.style || {}, left, top);
if (!newStyle) return;
await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory });
await this.update(
{ id: node.id, type: node.type, style: newStyle },
{ doNotPushHistory, historyDescription, historySource },
);
}
public resetState() {
@ -1350,9 +1412,17 @@ class Editor extends BaseService {
private pushOpHistory(
opType: HistoryOpType,
extra: Partial<StepValue>,
pageData: { name: string; id: Id },
historyDescription?: string,
{
extra,
pageData,
historyDescription,
source,
}: {
extra: Partial<StepValue>;
pageData: { name: string; id: Id };
historyDescription?: string;
source?: HistoryOpSource;
},
) {
const step: StepValue = {
data: pageData,
@ -1363,6 +1433,7 @@ class Editor extends BaseService {
...extra,
};
if (historyDescription) step.historyDescription = historyDescription;
if (source) step.source = source;
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
historyService.push(step, pageData.id);

View File

@ -27,6 +27,7 @@ import type {
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryState,
PageHistoryGroup,
PageHistoryStepEntry,
@ -280,6 +281,8 @@ class History extends BaseService {
changeRecords?: ChangeRecord[];
/** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */
historyDescription?: string;
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
source?: HistoryOpSource;
},
): CodeBlockStepValue | null {
if (!codeBlockId) return null;
@ -290,6 +293,7 @@ class History extends BaseService {
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
@ -310,6 +314,8 @@ class History extends BaseService {
changeRecords?: ChangeRecord[];
/** 可选的人类可读描述,仅用于历史面板展示。 */
historyDescription?: string;
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
source?: HistoryOpSource;
},
): DataSourceStepValue | null {
if (!dataSourceId) return null;
@ -320,6 +326,7 @@ class History extends BaseService {
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};

View File

@ -20,7 +20,7 @@ class Keybinding extends BaseService {
const nodes = editorService.get('nodes');
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
editorService.remove(nodes);
editorService.remove(nodes, { historySource: 'shortcut' });
},
[KeyBindingCommand.COPY_NODE]: () => {
const nodes = editorService.get('nodes');
@ -31,11 +31,11 @@ class Keybinding extends BaseService {
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
editorService.copy(nodes);
editorService.remove(nodes);
editorService.remove(nodes, { historySource: 'shortcut' });
},
[KeyBindingCommand.PASTE_NODE]: () => {
const nodes = editorService.get('nodes');
nodes && editorService.paste({ offsetX: 10, offsetY: 10 });
nodes && editorService.paste({ offsetX: 10, offsetY: 10 }, undefined, { historySource: 'shortcut' });
},
[KeyBindingCommand.UNDO]: () => {
editorService.undo();

View File

@ -281,6 +281,20 @@
white-space: nowrap;
}
// 操作途径徽标浅灰描边胶囊弱化展示来源画布 / 图层 / 配置面板不抢占描述焦点
.m-editor-history-list-item-source {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #dcdfe6;
border-radius: 8px;
font-size: 10px;
line-height: 14px;
color: #909399;
background-color: #f4f4f5;
white-space: nowrap;
font-weight: 400; // 防止被合并组头部的粗体继承
}
// 合并 N 徽标紫色实心胶囊与合并组卡片色系一致醒目区分单步条目
.m-editor-history-list-item-merge {
flex: 0 0 auto;

View File

@ -510,7 +510,7 @@ export interface HistoryListExtraTab {
* - data-source: 数据源 `type`(base/http/...) dataSourceService
* - code-block: 数据源代码块使
*/
export type CompareCategory = 'node' | 'data-source' | 'code-block';
export type CompareCategory = 'node' | 'data-source' | 'code-block' | string;
/**
* `loadConfig`
@ -683,6 +683,44 @@ export interface CodeParamStatement {
export type HistoryOpType = 'add' | 'remove' | 'update';
// #endregion HistoryOpType
// #region HistoryOpSource
/**
* /
* undo/redo UI
*
* - `stage` / /
* - `tree` / / /
* - `component-panel` /
* - `props`
* - `code` JSON/
* - `stage-contextmenu`
* - `tree-contextmenu` / /
* - `toolbar`
* - `shortcut`
* - `rollback` git revert
* - `api` /
* - `ai`AI /
* - `unknown`
*
* `(string & {})`
*/
export type HistoryOpSource =
| 'stage'
| 'tree'
| 'component-panel'
| 'props'
| 'code'
| 'stage-contextmenu'
| 'tree-contextmenu'
| 'toolbar'
| 'shortcut'
| 'rollback'
| 'api'
| 'ai'
| 'unknown'
| (string & {});
// #endregion HistoryOpSource
// #region StepValue
export interface StepValue {
/** 页面信息 */
@ -713,6 +751,12 @@ export interface StepValue {
* undo/redo / propPath
*/
historyDescription?: string;
/**
* {@link HistoryOpSource}
* / / / / / / / / /
* undo/redo
*/
source?: HistoryOpSource;
/**
* historyService.push
*/
@ -741,6 +785,8 @@ export interface CodeBlockStepValue {
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
source?: HistoryOpSource;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
@ -767,6 +813,8 @@ export interface DataSourceStepValue {
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
source?: HistoryOpSource;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
@ -1099,16 +1147,21 @@ export const canUsePluginMethods = {
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
// #region HistoryOpOptions
/**
* codeBlock / dataSource / editor
* - doNotPushHistory: 操作完成后是否不要将本次操作压入历史栈/ false
* - historyDescription: 入栈时附带的人类可读描述 undo/redo
* - historySource: 操作途径 {@link HistoryOpSource} / / / / / / / / / undo/redo
*/
export interface HistoryOpOptions {
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
}
// #endregion HistoryOpOptions
// #region HistoryOpOptionsWithChangeRecords
/**
* HistoryOpOptions form propPath/value
* / propPath patch
@ -1116,7 +1169,9 @@ export interface HistoryOpOptions {
export interface HistoryOpOptionsWithChangeRecords extends HistoryOpOptions {
changeRecords?: ChangeRecord[];
}
// #endregion HistoryOpOptionsWithChangeRecords
// #region DslOpOptions
/**
* DSL
* - doNotSelect: 操作后是否不要自动触发选中 this.select / this.multiSelect / stage.select / stage.multiSelect
@ -1126,6 +1181,7 @@ export interface DslOpOptions extends HistoryOpOptions {
doNotSelect?: boolean;
doNotSwitchPage?: boolean;
}
// #endregion DslOpOptions
/** 差异对话框的入参 */
export interface DiffDialogPayload {

View File

@ -5,11 +5,16 @@ import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core';
import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
import type { MenuButton, Services } from '@editor/type';
import type { HistoryOpSource, MenuButton, Services } from '@editor/type';
import { COPY_STORAGE_KEY } from './editor';
export const useDeleteMenu = (): MenuButton => ({
/**
* ViewerMenu LayerMenu
* `historySource`
* `'stage-contextmenu'` `'tree-contextmenu'`
*/
export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({
type: 'button',
text: '删除',
icon: Delete,
@ -19,7 +24,7 @@ export const useDeleteMenu = (): MenuButton => ({
},
handler: ({ editorService }) => {
const nodes = editorService.get('nodes');
nodes && editorService.remove(nodes);
nodes && editorService.remove(nodes, { historySource });
},
});
@ -33,7 +38,10 @@ export const useCopyMenu = (): MenuButton => ({
},
});
export const usePasteMenu = (menu?: ShallowRef<InstanceType<typeof ContentMenu> | null>): MenuButton => ({
export const usePasteMenu = (
historySource?: HistoryOpSource,
menu?: ShallowRef<InstanceType<typeof ContentMenu> | null>,
): MenuButton => ({
type: 'button',
text: '粘贴',
icon: markRaw(DocumentCopy),
@ -52,14 +60,14 @@ export const usePasteMenu = (menu?: ShallowRef<InstanceType<typeof ContentMenu>
const initialTop =
calcValueByFontsize(stage?.renderer?.getDocument(), (rect.top || 0) - (parentRect?.top || 0)) /
uiService.get('zoom');
editorService.paste({ left: initialLeft, top: initialTop });
editorService.paste({ left: initialLeft, top: initialTop }, undefined, { historySource });
} else {
editorService.paste();
editorService.paste(undefined, undefined, { historySource });
}
},
});
const moveTo = async (id: Id, { editorService }: Services) => {
const moveTo = async (id: Id, { editorService }: Services, historySource?: HistoryOpSource) => {
const nodes = editorService.get('nodes') || [];
const parent = editorService.getNodeById(id) as MContainer;
@ -69,10 +77,11 @@ const moveTo = async (id: Id, { editorService }: Services) => {
// 不要再走 remove + add 两步,否则会被切成两条历史(且语义也不正确)。
await editorService.moveToContainer(cloneDeep(nodes), parent.id, {
doNotSwitchPage: true,
historySource,
});
};
export const useMoveToMenu = ({ editorService }: Services): MenuButton => {
export const useMoveToMenu = ({ editorService }: Services, historySource?: HistoryOpSource): MenuButton => {
const root = computed(() => editorService.get('root'));
return {
@ -89,7 +98,7 @@ export const useMoveToMenu = ({ editorService }: Services): MenuButton => {
text: `${page.name}(${page.id})`,
type: 'button',
handler: (services: Services) => {
moveTo(page.id, services);
moveTo(page.id, services, historySource);
},
})),
};

View File

@ -104,7 +104,7 @@ describe('useCodeBlockEdit', () => {
const deleteCodeDslByIds = vi.fn();
const hook = mountHook({ deleteCodeDslByIds });
await hook.deleteCode('k');
expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k']);
expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k'], { historySource: undefined });
});
test('submitCodeBlockHandler - 没有 codeId 时跳过', async () => {
@ -119,7 +119,14 @@ describe('useCodeBlockEdit', () => {
const hook = mountHook({ setCodeDslById });
hook.codeId.value = 'id1';
await hook.submitCodeBlockHandler({ name: 'b' } as any);
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: undefined });
expect(setCodeDslById).toHaveBeenCalledWith(
'id1',
{ name: 'b' },
{
changeRecords: undefined,
historySource: 'props',
},
);
expect(hideMock).toHaveBeenCalled();
});
@ -129,6 +136,13 @@ describe('useCodeBlockEdit', () => {
hook.codeId.value = 'id1';
const records = [{ propPath: 'name', value: 'b' }];
await hook.submitCodeBlockHandler({ name: 'b' } as any, { changeRecords: records } as any);
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: records });
expect(setCodeDslById).toHaveBeenCalledWith(
'id1',
{ name: 'b' },
{
changeRecords: records,
historySource: 'props',
},
);
});
});

View File

@ -235,7 +235,7 @@ describe('useStage', () => {
test('sort 事件', () => {
useStage({} as any);
stageInstance.handlers.sort[0]({ src: 'a', dist: 'b' });
expect(editorService.sort).toHaveBeenCalledWith('a', 'b');
expect(editorService.sort).toHaveBeenCalledWith('a', 'b', { historySource: 'stage' });
});
test('remove 事件', () => {

View File

@ -97,7 +97,9 @@ describe('ComponentListPanel', () => {
test('点击 component-item 调用 editorService.add', async () => {
const wrapper = mount(ComponentListPanel);
await wrapper.find('.component-item').trigger('click');
expect(editorService.add).toHaveBeenCalledWith({ name: '按钮', type: 'button' });
expect(editorService.add).toHaveBeenCalledWith({ name: '按钮', type: 'button' }, undefined, {
historySource: 'component-panel',
});
});
test('搜索过滤组件', async () => {

View File

@ -70,7 +70,13 @@ describe('code-block useContentMenu', () => {
setCodeDslById: vi.fn(),
};
await (result.menuData[1] as any).handler({ codeBlockService });
expect(codeBlockService.setCodeDslById).toHaveBeenCalledWith('newId', { name: 'a' });
expect(codeBlockService.setCodeDslById).toHaveBeenCalledWith(
'newId',
{ name: 'a' },
{
historySource: 'tree-contextmenu',
},
);
});
test('复制按钮: 未选中时不触发', async () => {

View File

@ -187,7 +187,7 @@ describe('DataSourceListPanel', () => {
await wrapper.find('.remove-btn').trigger('click');
expect(messageBoxConfirm).toHaveBeenCalled();
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceService.remove).toHaveBeenCalledWith('d1');
expect(dataSourceService.remove).toHaveBeenCalledWith('d1', { historySource: 'tree-contextmenu' });
await wrapper.find('.ctx-btn').trigger('click');
expect(nodeContentMenuHandler).toHaveBeenCalled();
});

View File

@ -67,7 +67,7 @@ describe('data-source useContentMenu', () => {
add: vi.fn(),
};
(result.menuData[1] as any).handler({ dataSourceService });
expect(dataSourceService.add).toHaveBeenCalledWith({ name: 'a' });
expect(dataSourceService.add).toHaveBeenCalledWith({ name: 'a' }, { historySource: 'tree-contextmenu' });
});
test('复制按钮: 未选中时不触发', () => {

View File

@ -117,7 +117,9 @@ describe('LayerMenu', () => {
const addItem = arg.find((m: any) => m.text === '新增');
expect(addItem.items[0].text).toBe('标签页');
addItem.items[0].handler();
expect(editorService.add).toHaveBeenCalledWith({ type: 'tab-pane' });
expect(editorService.add).toHaveBeenCalledWith({ type: 'tab-pane' }, undefined, {
historySource: 'tree-contextmenu',
});
});
test('node.items 时根据组件列表生成子菜单 (含分隔)', () => {
@ -151,6 +153,8 @@ describe('LayerMenu', () => {
const arg = customContentMenu.mock.calls[0][0];
const addItem = arg.find((m: any) => m.text === '新增');
addItem.items[0].handler();
expect(editorService.add).toHaveBeenCalledWith({ name: 'btn', type: 'button' });
expect(editorService.add).toHaveBeenCalledWith({ name: 'btn', type: 'button' }, undefined, {
historySource: 'tree-contextmenu',
});
});
});

View File

@ -39,7 +39,7 @@ describe('LayerNodeTool', () => {
props: { data: { id: 'n1', type: 'text', visible: true } as any },
});
await wrapper.find('button').trigger('click');
expect(editorService.update).toHaveBeenCalledWith({ id: 'n1', visible: false });
expect(editorService.update).toHaveBeenCalledWith({ id: 'n1', visible: false }, { historySource: 'tree' });
});
test('点击按钮切换 visible 状态 (false -> true)', async () => {
@ -48,6 +48,6 @@ describe('LayerNodeTool', () => {
props: { data: { id: 'n2', type: 'text', visible: false } as any },
});
await wrapper.find('button').trigger('click');
expect(editorService.update).toHaveBeenCalledWith({ id: 'n2', visible: true });
expect(editorService.update).toHaveBeenCalledWith({ id: 'n2', visible: true }, { historySource: 'tree' });
});
});

View File

@ -126,9 +126,9 @@ describe('ViewerMenu.vue', () => {
});
const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[];
menuData.find((m: any) => m.text === '上移一层').handler();
expect(editorService.moveLayer).toHaveBeenCalledWith(1);
expect(editorService.moveLayer).toHaveBeenCalledWith(1, { historySource: 'stage-contextmenu' });
menuData.find((m: any) => m.text === '下移一层').handler();
expect(editorService.moveLayer).toHaveBeenCalledWith(-1);
expect(editorService.moveLayer).toHaveBeenCalledWith(-1, { historySource: 'stage-contextmenu' });
menuData.find((m: any) => m.text === '置顶').handler();
menuData.find((m: any) => m.text === '置底').handler();
expect(editorService.moveLayer).toHaveBeenCalledTimes(4);

View File

@ -114,9 +114,13 @@ describe('content-menu utils', () => {
toJSON: () => ({}),
});
const menu = ref<any>({ $el: menuEl });
const m = usePasteMenu(menu);
const m = usePasteMenu('stage-contextmenu', menu);
(m as any).handler({ editorService, uiService: { get: () => 2 } });
expect(paste).toHaveBeenCalledWith(expect.objectContaining({ left: expect.any(Number), top: expect.any(Number) }));
expect(paste).toHaveBeenCalledWith(
expect.objectContaining({ left: expect.any(Number), top: expect.any(Number) }),
undefined,
{ historySource: 'stage-contextmenu' },
);
});
test('useMoveToMenu - display 行为校验', () => {