feat(editor): 写操作支持 doNotPushHistory 选项以跳过历史记录

- editor/codeBlock/dataSource 的 add/update/delete 等接口新增 doNotPushHistory 选项
- 移除不再使用的 editor-history 工具及其单测
- 修复 layer 节点状态在重建时丢失已有 status 的问题
- 同步更新 service 方法文档,新增 dragto 复现用例
This commit is contained in:
roymondchen 2026-05-28 16:03:29 +08:00
parent e2c065f90d
commit 4c855ba50b
11 changed files with 410 additions and 794 deletions

View File

@ -48,6 +48,8 @@
- **参数:** - **参数:**
- `{string | number}` id 代码块id - `{string | number}` id 代码块id
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息 - {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -62,6 +64,8 @@
- `{string | number}` id 代码块id - `{string | number}` id 代码块id
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息 - {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
- `{boolean}` force 是否强制写入,默认 `true`;为 `false` 时若同 id 已存在则跳过 - `{boolean}` force 是否强制写入,默认 `true`;为 `false` 时若同 id 已存在则跳过
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{void}` - `{void}`
@ -73,6 +77,7 @@
::: tip ::: tip
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock` 写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock`
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。 把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
::: :::
## getCodeDslByIds ## getCodeDslByIds
@ -199,6 +204,8 @@
- **参数:** - **参数:**
- `{(string | number)[]}` codeIds 需要删除的代码块id数组 - `{(string | number)[]}` codeIds 需要删除的代码块id数组
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -209,7 +216,7 @@
::: tip ::: tip
对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条 对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条
`newContent=null` 的删除记录;不存在的 id 不会入历史。 `newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
::: :::
## setParamsColConfig ## setParamsColConfig

View File

@ -298,6 +298,8 @@ dataSourceService.setFormMethod("http", [
- **参数:** - **参数:**
- {`DataSourceSchema`} config 数据源配置 - {`DataSourceSchema`} config 数据源配置
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- {`DataSourceSchema`} 添加后的数据源配置 - {`DataSourceSchema`} 添加后的数据源配置
@ -309,6 +311,7 @@ dataSourceService.setFormMethod("http", [
::: tip ::: tip
添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录, 添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录,
参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。 参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
::: :::
- **示例:** - **示例:**
@ -334,6 +337,7 @@ console.log(newDs.id); // 自动生成的id
- {`DataSourceSchema`} config 数据源配置 - {`DataSourceSchema`} config 数据源配置
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录 - {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
::: details 查看 ChangeRecord 类型定义 ::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
@ -348,7 +352,7 @@ console.log(newDs.id); // 自动生成的id
::: tip ::: tip
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema` 更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema`
均为对应 schema 的更新记录。 均为对应 schema 的更新记录。传入 `doNotPushHistory: true` 可跳过写入历史栈。
::: :::
- **示例:** - **示例:**
@ -372,6 +376,8 @@ console.log(updatedDs);
- **参数:** - **参数:**
- `{string}` id 数据源id - `{string}` id 数据源id
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{void}` - `{void}`
@ -382,7 +388,7 @@ console.log(updatedDs);
::: tip ::: tip
对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null` 对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null`
的删除记录;不存在的 id 不会入历史。 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
::: :::
- **示例:** - **示例:**

View File

@ -358,6 +358,7 @@ editorService.highlight("text_123");
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点 - `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作) - `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合 - {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
@ -403,6 +404,7 @@ editorService.highlight("text_123");
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面 - `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面) - `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -455,6 +457,7 @@ editorService.highlight("text_123");
- {`MNode` | `MNode`[]} config 新的节点或节点集合 - {`MNode` | `MNode`[]} config 新的节点或节点集合
- `{Object}` data 可选配置 - `{Object}` data 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录 - {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合 - {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合
@ -481,6 +484,7 @@ editorService.highlight("text_123");
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false - `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致) - `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -548,6 +552,7 @@ editorService.highlight("text_123");
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false - `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false跨页粘贴时为 true 会跳过页面切换) - `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false跨页粘贴时为 true 会跳过页面切换)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置 - {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
@ -585,6 +590,7 @@ editorService.highlight("text_123");
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false - `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style方法内为空操作保留以与其它 DSL 操作 API 一致) - `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style方法内为空操作保留以与其它 DSL 操作 API 一致)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} - {Promise<`MNode` | `MNode`[]>}
@ -605,6 +611,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **参数:** - **参数:**
- `{number | 'top' | 'bottom'}` offset - `{number | 'top' | 'bottom'}` offset
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -625,6 +633,7 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false - `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换) - `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- Promise<`MNode` | undefined> - Promise<`MNode` | undefined>
@ -639,6 +648,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- {`MNode` | `MNode`[]} config 需要拖拽的节点或节点集合 - {`MNode` | `MNode`[]} config 需要拖拽的节点或节点集合
- {`MContainer`} targetParent 目标父容器 - {`MContainer`} targetParent 目标父容器
- `{number}` targetIndex 目标位置索引 - `{number}` targetIndex 目标位置索引
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -684,6 +695,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **参数:** - **参数:**
- `{number}` left - `{number}` left
- `{number}` top - `{number}` top
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`

View File

@ -9,12 +9,15 @@ import { updateStatus } from '@editor/utils/tree';
const createPageNodeStatus = (page: MPage | MPageFragment, initialLayerNodeStatus?: Map<Id, LayerNodeStatus>) => { const createPageNodeStatus = (page: MPage | MPageFragment, initialLayerNodeStatus?: Map<Id, LayerNodeStatus>) => {
const map = new Map<Id, LayerNodeStatus>(); const map = new Map<Id, LayerNodeStatus>();
map.set(page.id, { map.set(
visible: true, page.id,
expand: true, initialLayerNodeStatus?.get(page.id) || {
selected: true, visible: true,
draggable: false, expand: true,
}); selected: true,
draggable: false,
},
);
page.items.forEach((node: MNode) => page.items.forEach((node: MNode) =>
traverseNode<MNode>(node, (node) => { traverseNode<MNode>(node, (node) => {

View File

@ -93,10 +93,16 @@ class CodeBlock extends BaseService {
* ID和代码内容到源dsl * ID和代码内容到源dsl
* @param {Id} id id * @param {Id} id id
* @param {CodeBlockContent} codeConfig * @param {CodeBlockContent} codeConfig
* @param options
* @param options.doNotPushHistory false
* @returns {void} * @returns {void}
*/ */
public async setCodeDslById(id: Id, codeConfig: Partial<CodeBlockContent>): Promise<void> { public async setCodeDslById(
this.setCodeDslByIdSync(id, codeConfig, true); id: Id,
codeConfig: Partial<CodeBlockContent>,
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {},
): Promise<void> {
this.setCodeDslByIdSync(id, codeConfig, true, { doNotPushHistory });
} }
/** /**
@ -105,9 +111,16 @@ class CodeBlock extends BaseService {
* @param {Id} id id * @param {Id} id id
* @param {CodeBlockContent} codeConfig * @param {CodeBlockContent} codeConfig
* @param {boolean} force true * @param {boolean} force true
* @param options
* @param options.doNotPushHistory false
* @returns {void} * @returns {void}
*/ */
public setCodeDslByIdSync(id: Id, codeConfig: Partial<CodeBlockContent>, force = true): void { public setCodeDslByIdSync(
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {},
): void {
const codeDsl = this.getCodeDsl(); const codeDsl = this.getCodeDsl();
if (!codeDsl) { if (!codeDsl) {
@ -136,7 +149,9 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]); const newContent = cloneDeep(codeDsl[id]);
historyService.pushCodeBlock(id, { oldContent, newContent }); if (!doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent });
}
this.emit('addOrUpdate', id, codeDsl[id]); this.emit('addOrUpdate', id, codeDsl[id]);
} }
@ -226,8 +241,13 @@ class CodeBlock extends BaseService {
/** /**
* dsl数据源中删除指定id的代码块 * dsl数据源中删除指定id的代码块
* @param {Id[]} codeIds id数组 * @param {Id[]} codeIds id数组
* @param options
* @param options.doNotPushHistory false
*/ */
public async deleteCodeDslByIds(codeIds: Id[]): Promise<void> { public async deleteCodeDslByIds(
codeIds: Id[],
{ doNotPushHistory = false }: { doNotPushHistory?: boolean } = {},
): Promise<void> {
const currentDsl = await this.getCodeDsl(); const currentDsl = await this.getCodeDsl();
if (!currentDsl) return; if (!currentDsl) return;
@ -238,7 +258,7 @@ class CodeBlock extends BaseService {
delete currentDsl[id]; delete currentDsl[id];
if (oldContent) { if (oldContent && !doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent: null }); historyService.pushCodeBlock(id, { oldContent, newContent: null });
} }

View File

@ -103,7 +103,13 @@ class DataSource extends BaseService {
this.get('methods')[toLine(type)] = value; this.get('methods')[toLine(type)] = value;
} }
public add(config: DataSourceSchema) { /**
*
* @param config
* @param options
* @param options.doNotPushHistory false
*/
public add(config: DataSourceSchema, { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}) {
const newConfig = { const newConfig = {
...config, ...config,
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(), id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
@ -111,14 +117,29 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig); this.get('dataSources').push(newConfig);
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig }); if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig });
}
this.emit('add', newConfig); this.emit('add', newConfig);
return newConfig; return newConfig;
} }
public update(config: DataSourceSchema, { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {}) { /**
*
* @param config
* @param data
* @param data.changeRecords form
* @param data.doNotPushHistory false
*/
public update(
config: DataSourceSchema,
{
changeRecords = [],
doNotPushHistory = false,
}: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
) {
const dataSources = this.get('dataSources'); const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === config.id); const index = dataSources.findIndex((ds) => ds.id === config.id);
@ -128,10 +149,12 @@ class DataSource extends BaseService {
dataSources[index] = newConfig; dataSources[index] = newConfig;
historyService.pushDataSource(newConfig.id, { if (!doNotPushHistory) {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null, historyService.pushDataSource(newConfig.id, {
newSchema: newConfig, oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
}); newSchema: newConfig,
});
}
this.emit('update', newConfig, { this.emit('update', newConfig, {
oldConfig, oldConfig,
@ -141,13 +164,19 @@ class DataSource extends BaseService {
return newConfig; return newConfig;
} }
public remove(id: string) { /**
*
* @param id id
* @param options
* @param options.doNotPushHistory false
*/
public remove(id: string, { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}) {
const dataSources = this.get('dataSources'); const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === id); const index = dataSources.findIndex((ds) => ds.id === id);
const oldConfig = index !== -1 ? dataSources[index] : null; const oldConfig = index !== -1 ? dataSources[index] : null;
dataSources.splice(index, 1); dataSources.splice(index, 1);
if (oldConfig) { if (oldConfig && !doNotPushHistory) {
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null }); historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null });
} }

View File

@ -62,11 +62,9 @@ import {
setLayout, setLayout,
toggleFixedPosition, toggleFixedPosition,
} from '@editor/utils/editor'; } from '@editor/utils/editor';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
import { beforePaste, getAddParent } from '@editor/utils/operator'; import { beforePaste, getAddParent } from '@editor/utils/operator';
type MoveItem = { cfg: MNode; node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null }; type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
class Editor extends BaseService { class Editor extends BaseService {
public state: StoreState = reactive({ public state: StoreState = reactive({
@ -84,7 +82,6 @@ class Editor extends BaseService {
disabledMultiSelect: false, disabledMultiSelect: false,
alwaysMultiSelect: false, alwaysMultiSelect: false,
}); });
private isHistoryStateChange = false;
private selectionBeforeOp: Id[] | null = null; private selectionBeforeOp: Id[] | null = null;
constructor() { constructor() {
@ -371,12 +368,13 @@ class Editor extends BaseService {
* @param options * @param options
* @param options.doNotSelect false * @param options.doNotSelect false
* @param options.doNotSwitchPage false / true * @param options.doNotSwitchPage false / true
* @param options.doNotPushHistory false
* @returns * @returns
*/ */
public async add( public async add(
addNode: AddMNode | MNode[], addNode: AddMNode | MNode[],
parent?: MContainer | null, parent?: MContainer | null,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {}, { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<MNode | MNode[]> { ): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp(); this.captureSelectionBeforeOp();
@ -435,20 +433,24 @@ class Editor extends BaseService {
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) { if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page; const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
this.pushOpHistory( if (!doNotPushHistory) {
'add', this.pushOpHistory(
{ 'add',
nodes: newNodes.map((n) => cloneDeep(toRaw(n))), {
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id, nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
indexMap: Object.fromEntries( parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
newNodes.map((n) => { indexMap: Object.fromEntries(
const p = this.getParentById(n.id, false) as MContainer; newNodes.map((n) => {
return [n.id, p ? getNodeIndex(n.id, p) : -1]; const p = this.getParentById(n.id, false) as MContainer;
}), return [n.id, p ? getNodeIndex(n.id, p) : -1];
), }),
}, ),
{ name: pageForOp?.name || '', id: pageForOp!.id }, },
); { name: pageForOp?.name || '', id: pageForOp!.id },
);
} else {
this.selectionBeforeOp = null;
}
} }
this.emit('add', newNodes); this.emit('add', newNodes);
@ -538,10 +540,11 @@ class Editor extends BaseService {
* @param options * @param options
* @param options.doNotSelect false * @param options.doNotSelect false
* @param options.doNotSwitchPage false / true * @param options.doNotSwitchPage false / true
* @param options.doNotPushHistory false
*/ */
public async remove( public async remove(
nodeOrNodeList: MNode | MNode[], nodeOrNodeList: MNode | MNode[],
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {}, { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<void> { ): Promise<void> {
this.captureSelectionBeforeOp(); this.captureSelectionBeforeOp();
@ -569,7 +572,11 @@ class Editor extends BaseService {
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage }))); await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
if (removedItems.length > 0 && pageForOp) { if (removedItems.length > 0 && pageForOp) {
this.pushOpHistory('remove', { removedItems }, pageForOp); if (!doNotPushHistory) {
this.pushOpHistory('remove', { removedItems }, pageForOp);
} else {
this.selectionBeforeOp = null;
}
} }
this.emit('remove', nodes); this.emit('remove', nodes);
@ -650,34 +657,42 @@ class Editor extends BaseService {
* *
* update后会触发依赖收集stage.update方法 * update后会触发依赖收集stage.update方法
* @param config id信息 * @param config id信息
* @param data
* @param data.changeRecords form
* @param data.doNotPushHistory false
* @returns * @returns
*/ */
public async update( public async update(
config: MNode | MNode[], config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[] } = {}, data: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
): Promise<MNode | MNode[]> { ): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp(); this.captureSelectionBeforeOp();
const { doNotPushHistory = false } = data;
const nodes = Array.isArray(config) ? config : [config]; const nodes = Array.isArray(config) ? config : [config];
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data))); const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
if (updateData[0].oldNode?.type !== NodeType.ROOT) { if (updateData[0].oldNode?.type !== NodeType.ROOT) {
const curNodes = this.get('nodes'); const curNodes = this.get('nodes');
if (!this.isHistoryStateChange && curNodes.length) { if (curNodes.length) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page; if (!doNotPushHistory) {
this.pushOpHistory( const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
'update', this.pushOpHistory(
{ 'update',
updatedItems: updateData.map((d) => ({ {
oldNode: cloneDeep(d.oldNode), updatedItems: updateData.map((d) => ({
newNode: cloneDeep(toRaw(d.newNode)), oldNode: cloneDeep(d.oldNode),
})), newNode: cloneDeep(toRaw(d.newNode)),
}, })),
{ name: pageForOp?.name || '', id: pageForOp!.id }, },
); { name: pageForOp?.name || '', id: pageForOp!.id },
);
} else {
this.selectionBeforeOp = null;
}
} }
this.isHistoryStateChange = false;
} }
this.emit('update', updateData); this.emit('update', updateData);
@ -691,9 +706,14 @@ class Editor extends BaseService {
* @param options * @param options
* @param options.doNotSelect false * @param options.doNotSelect false
* @param options.doNotSwitchPage DSL API * @param options.doNotSwitchPage DSL API
* @param options.doNotPushHistory false
* @returns void * @returns void
*/ */
public async sort(id1: Id, id2: Id, { doNotSelect = false }: DslOpOptions = {}): Promise<void> { public async sort(
id1: Id,
id2: Id,
{ doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp(); this.captureSelectionBeforeOp();
const root = this.get('root'); const root = this.get('root');
@ -712,7 +732,7 @@ class Editor extends BaseService {
parent.items.splice(index2, 0, ...parent.items.splice(index1, 1)); parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
await this.update(parent); await this.update(parent, { doNotPushHistory });
if (!doNotSelect) { if (!doNotSelect) {
await this.select(node); await this.select(node);
} }
@ -759,12 +779,13 @@ class Editor extends BaseService {
* @param options * @param options
* @param options.doNotSelect false * @param options.doNotSelect false
* @param options.doNotSwitchPage false true * @param options.doNotSwitchPage false true
* @param options.doNotPushHistory false
* @returns * @returns
*/ */
public async paste( public async paste(
position: PastePosition = {}, position: PastePosition = {},
collectorOptions?: TargetOptions, collectorOptions?: TargetOptions,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {}, { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<MNode | MNode[] | void> { ): Promise<MNode | MNode[] | void> {
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY); const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return; if (!Array.isArray(config)) return;
@ -785,7 +806,7 @@ class Editor extends BaseService {
propsService.replaceRelateId(config, pasteConfigs, collectorOptions); propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
} }
return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage }); return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage, doNotPushHistory });
} }
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> { public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@ -816,18 +837,19 @@ class Editor extends BaseService {
* @param options * @param options
* @param options.doNotSelect false * @param options.doNotSelect false
* @param options.doNotSwitchPage style DSL API * @param options.doNotSwitchPage style DSL API
* @param options.doNotPushHistory false
* @returns * @returns
*/ */
public async alignCenter( public async alignCenter(
config: MNode | MNode[], config: MNode | MNode[],
{ doNotSelect = false }: DslOpOptions = {}, { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<MNode | MNode[]> { ): Promise<MNode | MNode[]> {
const nodes = Array.isArray(config) ? config : [config]; const nodes = Array.isArray(config) ? config : [config];
const stage = this.get('stage'); const stage = this.get('stage');
const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node))); const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
const newNode = await this.update(newNodes); const newNode = await this.update(newNodes, { doNotPushHistory });
if (!doNotSelect) { if (!doNotSelect) {
if (newNodes.length > 1) { if (newNodes.length > 1) {
@ -843,8 +865,10 @@ class Editor extends BaseService {
/** /**
* *
* @param offset * @param offset
* @param options
* @param options.doNotPushHistory false
*/ */
public async moveLayer(offset: number | LayerOffset): Promise<void> { public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise<void> {
this.captureSelectionBeforeOp(); this.captureSelectionBeforeOp();
const root = this.get('root'); const root = this.get('root');
@ -881,14 +905,18 @@ class Editor extends BaseService {
}); });
this.addModifiedNodeId(parent.id); this.addModifiedNodeId(parent.id);
const pageForOp = this.getNodeInfo(node.id, false).page; if (!doNotPushHistory) {
this.pushOpHistory( const pageForOp = this.getNodeInfo(node.id, false).page;
'update', this.pushOpHistory(
{ 'update',
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }], {
}, updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
{ name: pageForOp?.name || '', id: pageForOp!.id }, },
); { name: pageForOp?.name || '', id: pageForOp!.id },
);
} else {
this.selectionBeforeOp = null;
}
this.emit('move-layer', offset); this.emit('move-layer', offset);
} }
@ -905,11 +933,12 @@ class Editor extends BaseService {
* @param options * @param options
* @param options.doNotSelect false * @param options.doNotSelect false
* @param options.doNotSwitchPage false true * @param options.doNotSwitchPage false true
* @param options.doNotPushHistory false
*/ */
public async moveToContainer( public async moveToContainer(
config: MNode | MNode[], config: MNode | MNode[],
targetId: Id, targetId: Id,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {}, { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<MNode | MNode[]> { ): Promise<MNode | MNode[]> {
const isBatch = Array.isArray(config); const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item))); const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
@ -935,10 +964,10 @@ class Editor extends BaseService {
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身) // 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
const moves: MoveItem[] = []; const moves: MoveItem[] = [];
for (const cfg of configs) { for (const { id } of configs) {
const { node, parent, page } = this.getNodeInfo(cfg.id, false); const { node, parent, page } = this.getNodeInfo(id, false);
if (!node || !parent) continue; if (!node || !parent) continue;
moves.push({ cfg, node, parent, pageForOp: page ? { name: page.name || '', id: page.id } : null }); moves.push({ node, parent, pageForOp: page ? { name: page.name || '', id: page.id } : null });
} }
if (moves.length === 0) { if (moves.length === 0) {
@ -948,96 +977,44 @@ class Editor extends BaseService {
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。 // 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
const beforeSnapshots = new Map<Id, MNode>(); const beforeSnapshots = new Map<Id, MNode>();
beforeSnapshots.set(target.id, cloneDeep(toRaw(target))); beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
for (const m of moves) { for (const { parent } of moves) {
if (!beforeSnapshots.has(m.parent.id)) { if (!beforeSnapshots.has(parent.id)) {
beforeSnapshots.set(m.parent.id, cloneDeep(toRaw(m.parent))); beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
} }
} }
const layout = await this.getLayout(target); let newConfigs: MNode[] = [];
const newConfigs: MNode[] = [];
for (const { cfg, node, parent } of moves) { const moveNodes = moves.map(({ node }) => node);
const index = getNodeIndex(node.id, parent); await this.remove(moveNodes, { doNotPushHistory: true, doNotSelect, doNotSwitchPage: true });
parent.items?.splice(index, 1);
await stage.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) }); newConfigs = (await this.add(moveNodes, target, {
doNotPushHistory: true,
doNotSelect,
doNotSwitchPage,
})) as MNode[];
const newConfig = mergeWith(cloneDeep(node), cfg, (_objValue, srcValue) => { if (!doNotPushHistory) {
if (Array.isArray(srcValue)) { // 整批只入栈一条历史updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
return srcValue; const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
} oldNode,
}); newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
newConfig.style = getInitPositionStyle(newConfig.style, layout); }));
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
target.items.push(newConfig); this.pushOpHistory('update', { updatedItems }, historyPage);
newConfigs.push(newConfig);
this.addModifiedNodeId(parent.id);
}
this.addModifiedNodeId(target.id);
// 目标容器是否在非当前页面:选中目标会触发当前页面切换
const targetWouldSwitchPage = this.isOnDifferentPage(target);
const skipSelect = doNotSelect || (doNotSwitchPage && targetWouldSwitchPage);
if (!skipSelect) {
await stage.select(targetId);
}
const targetParent = this.getParentById(target.id);
await stage.update({
config: cloneDeep(target),
parentId: targetParent?.id,
root: cloneDeep(root),
});
if (!skipSelect) {
if (newConfigs.length > 1) {
const ids = newConfigs.map((n) => n.id);
await this.multiSelect(ids);
stage.multiSelect(ids);
} else {
await this.select(newConfigs[0]);
stage.select(newConfigs[0].id);
}
} else { } else {
// 跳过选中目标节点(通常是因为目标位于其它页面),但 state.nodes 仍持有已经被 this.selectionBeforeOp = null;
// 从源父容器中移除的旧节点引用 —— UI 上原页面会保留一个"指向不存在节点"的选中态。
// 这里需要把搬走的节点从 state.nodes 中剔除:
// - 如果剔除后还有剩余选中(部分被搬走、部分未动),保持新的多选状态;
// - 如果选中节点全部已被搬走,回退到第一个源父容器(与 `doRemove` 默认在原页面选中父节点的行为一致)。
const movedIds = new Set(moves.map((m) => `${m.node.id}`));
const selectedNodes = this.get('nodes');
const remained = selectedNodes.filter((n: MNode) => !movedIds.has(`${n.id}`));
if (remained.length === selectedNodes.length) {
// 当前选中根本不在被搬走的列表里,无需调整
} else if (remained.length > 0) {
this.multiSelect(remained.map((n: MNode) => n.id));
} else {
// 全部被搬走:选中源父容器,避免残留旧引用。多源父时取第一个,与单选场景默认表现一致。
const fallbackParent = moves[0].parent;
try {
await this.select(fallbackParent);
} catch {
this.set('nodes', []);
}
}
} }
// 整批只入栈一条历史updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
oldNode,
newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
}));
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
this.pushOpHistory('update', { updatedItems }, historyPage);
return isBatch ? newConfigs : newConfigs[0]; return isBatch ? newConfigs : newConfigs[0];
} }
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) { public async dragTo(
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
{ doNotPushHistory = false }: DslOpOptions = {},
) {
this.captureSelectionBeforeOp(); this.captureSelectionBeforeOp();
if (!targetParent || !Array.isArray(targetParent.items)) return; if (!targetParent || !Array.isArray(targetParent.items)) return;
@ -1097,8 +1074,12 @@ class Editor extends BaseService {
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) }); updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
} }
} }
const pageForOp = this.getNodeInfo(configs[0].id, false).page; if (!doNotPushHistory) {
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id }); const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
} else {
this.selectionBeforeOp = null;
}
this.emit('drag-to', { targetIndex, configs, targetParent }); this.emit('drag-to', { targetIndex, configs, targetParent });
} }
@ -1127,14 +1108,14 @@ class Editor extends BaseService {
return value; return value;
} }
public async move(left: number, top: number) { public async move(left: number, top: number, { doNotPushHistory = false }: DslOpOptions = {}) {
const node = toRaw(this.get('node')); const node = toRaw(this.get('node'));
if (!node || isPage(node)) return; if (!node || isPage(node)) return;
const newStyle = calcMoveStyle(node.style || {}, left, top); const newStyle = calcMoveStyle(node.style || {}, left, top);
if (!newStyle) return; if (!newStyle) return;
await this.update({ id: node.id, type: node.type, style: newStyle }); await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory });
} }
public resetState() { public resetState() {
@ -1182,22 +1163,15 @@ class Editor extends BaseService {
} }
private addModifiedNodeId(id: Id) { private addModifiedNodeId(id: Id) {
if (!this.isHistoryStateChange) { this.get('modifiedNodeIds').set(id, id);
this.get('modifiedNodeIds').set(id, id);
}
} }
private captureSelectionBeforeOp() { private captureSelectionBeforeOp() {
if (this.isHistoryStateChange || this.selectionBeforeOp) return; if (this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id); this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
} }
private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) { private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
if (this.isHistoryStateChange) {
this.selectionBeforeOp = null;
return;
}
const step: StepValue = { const step: StepValue = {
data: pageData, data: pageData,
opType, opType,
@ -1210,41 +1184,100 @@ class Editor extends BaseService {
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。 // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
historyService.push(step, pageData.id); historyService.push(step, pageData.id);
this.selectionBeforeOp = null; this.selectionBeforeOp = null;
this.isHistoryStateChange = false;
} }
/** /**
* / * /
*
* DSL `editor.add / remove / update` `doNotPushHistory`
* `doNotSelect / doNotSwitchPage`
*
* add / remove / update "用户操作""撤销重做触发"
* `history-change`
*
* @param step * @param step
* @param reverse true = false = * @param reverse true = false =
*/ */
private async applyHistoryOp(step: StepValue, reverse: boolean) { private async applyHistoryOp(step: StepValue, reverse: boolean) {
this.isHistoryStateChange = true;
const root = this.get('root'); const root = this.get('root');
const stage = this.get('stage'); const stage = this.get('stage');
if (!root) return; if (!root) return;
const ctx: HistoryOpContext = { const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const;
root,
stage,
getNodeById: (id, raw) => this.getNodeById(id, raw),
getNodeInfo: (id, raw) => this.getNodeInfo(id, raw),
setRoot: (r) => this.set('root', r),
setPage: (p) => this.set('page', p),
getPage: () => this.get('page'),
};
switch (step.opType) { switch (step.opType) {
case 'add': case 'add': {
await applyHistoryAddOp(step, reverse, ctx); const nodes = step.nodes ?? [];
if (reverse) {
// 撤销 add把当时加入的节点删除
for (const n of nodes) {
const existing = this.getNodeById(n.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
}
} else {
// 重做 add按记录的 indexMap 把节点重新插回父容器
const parent = this.getNodeById(step.parentId!, false) as MContainer | null;
if (parent) {
// 按目标 index 升序逐个插入,先小后大避免索引漂移
const sorted = [...nodes].sort((a, b) => (step.indexMap?.[a.id] ?? 0) - (step.indexMap?.[b.id] ?? 0));
for (const n of sorted) {
const idx = step.indexMap?.[n.id];
if (parent.items) {
if (typeof idx === 'number' && idx >= 0 && idx < parent.items.length) {
parent.items.splice(idx, 0, cloneDeep(n));
} else {
parent.items.push(cloneDeep(n));
}
await stage?.add({
config: cloneDeep(n),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
}
break; break;
case 'remove': }
await applyHistoryRemoveOp(step, reverse, ctx); case 'remove': {
const items = step.removedItems ?? [];
if (reverse) {
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移)
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
parent.items.splice(index, 0, cloneDeep(node));
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId,
root: cloneDeep(root),
});
}
}
} else {
// 重做 remove再删一次
for (const { node } of items) {
const existing = this.getNodeById(node.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
}
}
break; break;
case 'update': }
await applyHistoryUpdateOp(step, reverse, ctx); case 'update': {
const items = step.updatedItems ?? [];
const configs = items.map(({ oldNode, newNode }) => cloneDeep(reverse ? oldNode : newNode));
if (configs.length) {
await this.update(configs, { doNotPushHistory: true });
}
break; break;
}
} }
this.set('modifiedNodeIds', step.modifiedNodeIds); this.set('modifiedNodeIds', step.modifiedNodeIds);
@ -1266,8 +1299,6 @@ class Editor extends BaseService {
}, 0); }, 0);
this.emit('history-change', page as MPage | MPageFragment); this.emit('history-change', page as MPage | MPageFragment);
} }
this.isHistoryStateChange = false;
} }
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo { private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {

View File

@ -932,8 +932,10 @@ export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
* DSL * DSL
* - doNotSelect: 操作后是否不要自动触发选中 this.select / this.multiSelect / stage.select / stage.multiSelect * - doNotSelect: 操作后是否不要自动触发选中 this.select / this.multiSelect / stage.select / stage.multiSelect
* - doNotSwitchPage: 操作若会引发当前页面切换 / / * - doNotSwitchPage: 操作若会引发当前页面切换 / /
* - doNotPushHistory: 操作完成后是否不要将本次操作压入历史栈/
*/ */
export type DslOpOptions = { export type DslOpOptions = {
doNotSelect?: boolean; doNotSelect?: boolean;
doNotSwitchPage?: boolean; doNotSwitchPage?: boolean;
doNotPushHistory?: boolean;
}; };

View File

@ -1,138 +0,0 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { toRaw } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type StageCore from '@tmagic/stage';
import { isPage, isPageFragment } from '@tmagic/utils';
import type { EditorNodeInfo, StepValue } from '@editor/type';
import { getNodeIndex } from '@editor/utils/editor';
export interface HistoryOpContext {
root: MApp;
stage: StageCore | null;
getNodeById(id: Id, raw?: boolean): MNode | null;
getNodeInfo(id: Id, raw?: boolean): EditorNodeInfo;
setRoot(root: MApp): void;
setPage(page: MPage | MPageFragment): void;
getPage(): MPage | MPageFragment | null;
}
/**
* add
* reverse=true
* reverse=false
*/
export async function applyHistoryAddOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
for (const node of step.nodes ?? []) {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
}
} else {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (parent?.items) {
for (const node of step.nodes ?? []) {
const idx = step.indexMap?.[node.id] ?? parent.items.length;
parent.items.splice(idx, 0, cloneDeep(node));
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
}
/**
* remove
* reverse=true
* reverse=false
*/
export async function applyHistoryRemoveOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
parent.items.splice(index, 0, cloneDeep(node));
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
}
} else {
for (const { node, parentId } of step.removedItems ?? []) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
}
}
}
/**
* update
* reverse=true oldNode
* reverse=false newNode
*/
export async function applyHistoryUpdateOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
const items = step.updatedItems ?? [];
for (const { oldNode, newNode } of items) {
const config = reverse ? oldNode : newNode;
if (config.type === NodeType.ROOT) {
ctx.setRoot(cloneDeep(config) as MApp);
continue;
}
const info = ctx.getNodeInfo(config.id, false);
if (!info.parent) continue;
const idx = getNodeIndex(config.id, info.parent);
if (typeof idx !== 'number' || idx === -1) continue;
info.parent.items![idx] = cloneDeep(config);
if (isPage(config) || isPageFragment(config)) {
ctx.setPage(config as MPage | MPageFragment);
}
}
const curPage = ctx.getPage();
if (stage && curPage) {
await stage.update({
config: cloneDeep(toRaw(curPage)),
parentId: root.id,
root: cloneDeep(toRaw(root)),
});
}
}

View File

@ -0,0 +1,110 @@
import { beforeAll, describe, expect, test } from 'vitest';
import { cloneDeep } from 'lodash-es';
import type { MApp, MContainer } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import { setEditorConfig } from '@editor/utils';
setEditorConfig({
// eslint-disable-next-line no-eval
parseDSL: (dsl: string) => eval(dsl),
});
class LocalStorageMock {
public length = 0;
private store: Record<string, string> = {};
clear() {
this.store = {};
this.length = 0;
}
getItem(key: string) {
return this.store[key] || null;
}
setItem(key: string, value: string) {
this.store[key] = String(value);
this.length += 1;
}
removeItem(key: string) {
delete this.store[key];
this.length -= 1;
}
key(key: number) {
return Object.keys(this.store)[key];
}
}
globalThis.localStorage = new LocalStorageMock();
const ROOT_ID = 1;
const PAGE_ID = 2;
const CONTAINER_ID = 10;
const NODE_A = 11;
const NODE_B = 12;
const root: MApp = {
id: ROOT_ID,
type: NodeType.ROOT,
items: [
{
id: PAGE_ID,
type: NodeType.PAGE,
layout: 'absolute',
style: { width: 375 },
items: [
{
id: CONTAINER_ID,
type: NodeType.CONTAINER,
layout: 'absolute',
style: {},
items: [
{ id: NODE_A, type: 'text', style: {} },
{ id: NODE_B, type: 'text', style: {} },
],
},
],
},
],
};
describe('dragTo undo/redo selection', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('dragTo 内同父排序后 undo/redo 不应在 nodes 中同时残留页面和组件', async () => {
historyService.reset();
await editorService.select(NODE_A);
expect(editorService.get('nodes').map((n) => n.id)).toEqual([NODE_A]);
const container = editorService.getNodeById(CONTAINER_ID, false) as MContainer;
const nodeA = editorService.getNodeById(NODE_A, false)!;
// 同容器内拖到末尾
await editorService.dragTo([nodeA], container, 2);
console.log(
'after dragTo nodes:',
editorService.get('nodes').map((n) => n.id),
);
await editorService.undo();
// setTimeout(0) -> wait
await new Promise((r) => setTimeout(r, 20));
console.log(
'after undo nodes:',
editorService.get('nodes').map((n) => n.id),
);
console.log('after undo page:', editorService.get('page')?.id);
await editorService.redo();
await new Promise((r) => setTimeout(r, 20));
console.log(
'after redo nodes:',
editorService.get('nodes').map((n) => n.id),
);
// 假设undo 后 nodes 不应同时含有 page 和 component
const undoNodes = editorService.get('nodes').map((n) => n.id);
expect(undoNodes).not.toContain(PAGE_ID);
});
});

View File

@ -1,467 +0,0 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, test, vi } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { StepValue } from '@editor/type';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
const makePage = (): MContainer => ({
id: 'page_1',
type: NodeType.PAGE,
items: [
{ id: 'n1', type: 'text' },
{ id: 'n2', type: 'button' },
],
});
const makeRoot = (page: MContainer): MApp => ({
id: 'app_1',
type: NodeType.ROOT,
items: [page],
});
const makeCtx = (root: MApp): HistoryOpContext => {
const page = root.items[0] as MContainer;
return {
root,
stage: {
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
} as any,
getNodeById: (id: any) => {
if (`${id}` === `${root.id}`) return root as unknown as MNode;
if (`${id}` === `${page.id}`) return page as unknown as MNode;
return page.items.find((n) => `${n.id}` === `${id}`) ?? null;
},
getNodeInfo: (id: any) => {
if (`${id}` === `${page.id}`) {
return { node: page as unknown as MNode, parent: root as unknown as MContainer, page: page as any };
}
const node = page.items.find((n) => `${n.id}` === `${id}`);
return { node: node ?? null, parent: node ? page : null, page: page as any };
},
setRoot: vi.fn(),
setPage: vi.fn(),
getPage: () => page as any,
};
};
describe('applyHistoryAddOp', () => {
test('撤销 add从父节点移除已添加的节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['n1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'n1', type: 'text' }],
parentId: 'page_1',
};
expect(page.items).toHaveLength(2);
await applyHistoryAddOp(step, true, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
test('重做 add重新添加节点到父节点', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['new1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'new1', type: 'text' }],
parentId: 'page_1',
indexMap: { new1: 0 },
};
await applyHistoryAddOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('new1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
});
describe('applyHistoryRemoveOp', () => {
test('撤销 remove将已删除节点按原位置重新插入', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'n2', type: 'button' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: ['n1'],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
await applyHistoryRemoveOp(step, true, ctx);
expect(page.items).toHaveLength(2);
expect(page.items[0].id).toBe('n1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
test('重做 remove再次删除节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
expect(page.items).toHaveLength(2);
await applyHistoryRemoveOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
});
describe('applyHistoryUpdateOp', () => {
test('撤销 update将节点恢复为 oldNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(page.items[0].text).toBe('before');
expect(ctx.stage!.update).toHaveBeenCalled();
});
test('重做 update将节点更新为 newNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(page.items[0].text).toBe('after');
});
test('update ROOT 类型调用 setRoot', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'app_1', type: NodeType.ROOT, items: [] } as any,
newNode: { id: 'app_1', type: NodeType.ROOT, items: [page] } as any,
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(ctx.setRoot).toHaveBeenCalled();
});
test('update 页面节点调用 setPage', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const updatedPage = { ...page, name: 'renamed' };
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: page as any,
newNode: updatedPage as any,
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(ctx.setPage).toHaveBeenCalled();
});
});
describe('editor-history 边界分支', () => {
test('applyHistoryAddOp - parent 不存在时跳过 splice 调用', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'n1', type: 'text' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
ctx.getNodeById = (() => null) as any;
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
nodes: [{ id: 'n1', type: 'text' }],
parentId: 'missing',
};
await applyHistoryAddOp(step, true, ctx);
expect(page.items).toHaveLength(1);
});
test('applyHistoryAddOp - 节点不在父节点中时不抛错', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'other', type: 'text' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
nodes: [{ id: 'missing', type: 'text' }],
parentId: 'page_1',
};
await applyHistoryAddOp(step, true, ctx);
expect(page.items).toHaveLength(1);
});
test('applyHistoryAddOp - 重做时无 indexMap 默认追加到末尾', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'first', type: 'text' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
nodes: [{ id: 'last', type: 'text' }],
parentId: 'page_1',
};
await applyHistoryAddOp(step, false, ctx);
expect(page.items[page.items.length - 1].id).toBe('last');
});
test('applyHistoryAddOp - parent 不存在时重做无副作用 (else 分支)', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [] };
const root = makeRoot(page);
const ctx = makeCtx(root);
ctx.getNodeById = (() => null) as any;
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
nodes: [{ id: 'n1', type: 'text' }],
parentId: 'missing',
};
await applyHistoryAddOp(step, false, ctx);
expect(page.items).toHaveLength(0);
});
test('applyHistoryAddOp - nodes 缺失时使用空数组', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step = {
opType: 'add',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
parentId: 'page_1',
} as any;
await applyHistoryAddOp(step, true, ctx);
await applyHistoryAddOp(step, false, ctx);
expect(page.items).toHaveLength(2);
});
test('applyHistoryRemoveOp - parent 不存在跳过 (撤销/重做)', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
ctx.getNodeById = (() => null) as any;
const step: StepValue = {
opType: 'remove',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'missing', index: 0 }],
};
await applyHistoryRemoveOp(step, true, ctx);
await applyHistoryRemoveOp(step, false, ctx);
expect(page.items).toHaveLength(2);
});
test('applyHistoryRemoveOp - 节点不在父节点中时不报错', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'other', type: 'text' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'missing', type: 'text' }, parentId: 'page_1', index: 0 }],
};
await applyHistoryRemoveOp(step, false, ctx);
expect(page.items).toHaveLength(1);
});
test('applyHistoryRemoveOp - removedItems 缺失走默认空数组', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step = {
opType: 'remove',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
} as any;
await applyHistoryRemoveOp(step, true, ctx);
await applyHistoryRemoveOp(step, false, ctx);
expect(page.items).toHaveLength(2);
});
test('applyHistoryUpdateOp - info.parent 缺失时跳过', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
ctx.getNodeInfo = (() => ({ node: null, parent: null, page: null })) as any;
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [{ oldNode: { id: 'n1', type: 'text' }, newNode: { id: 'n1', type: 'text', text: 'x' } }],
};
await applyHistoryUpdateOp(step, false, ctx);
expect((page.items[0] as any).text).toBeUndefined();
});
test('applyHistoryUpdateOp - 节点不在父节点 items 时跳过', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
ctx.getNodeInfo = (() => ({ node: null, parent: page, page: page as any })) as any;
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [{ oldNode: { id: 'missing', type: 'text' }, newNode: { id: 'missing', type: 'text', text: 'x' } }],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(page.items.find((i) => i.id === 'missing')).toBeUndefined();
});
test('applyHistoryUpdateOp - stage 为 null 时跳过 stage.update', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
ctx.stage = null;
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [{ oldNode: { id: 'n1', type: 'text' }, newNode: { id: 'n1', type: 'text', text: 'x' } }],
};
await applyHistoryUpdateOp(step, false, ctx);
expect((page.items[0] as any).text).toBe('x');
});
test('applyHistoryUpdateOp - getPage 返回 null 时跳过 stage.update', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
ctx.getPage = () => null;
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [{ oldNode: { id: 'n1', type: 'text' }, newNode: { id: 'n1', type: 'text', text: 'y' } }],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(ctx.stage!.update).not.toHaveBeenCalled();
});
test('applyHistoryUpdateOp - updatedItems 缺失时安全', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
} as any;
await applyHistoryUpdateOp(step, false, ctx);
expect(ctx.stage!.update).toHaveBeenCalled();
});
});