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

View File

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

View File

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

View File

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

View File

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

View File

@ -103,7 +103,13 @@ class DataSource extends BaseService {
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 = {
...config,
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
@ -111,14 +117,29 @@ class DataSource extends BaseService {
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);
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 index = dataSources.findIndex((ds) => ds.id === config.id);
@ -128,10 +149,12 @@ class DataSource extends BaseService {
dataSources[index] = newConfig;
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
});
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
});
}
this.emit('update', newConfig, {
oldConfig,
@ -141,13 +164,19 @@ class DataSource extends BaseService {
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 index = dataSources.findIndex((ds) => ds.id === id);
const oldConfig = index !== -1 ? dataSources[index] : null;
dataSources.splice(index, 1);
if (oldConfig) {
if (oldConfig && !doNotPushHistory) {
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null });
}

View File

@ -62,11 +62,9 @@ import {
setLayout,
toggleFixedPosition,
} 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';
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 {
public state: StoreState = reactive({
@ -84,7 +82,6 @@ class Editor extends BaseService {
disabledMultiSelect: false,
alwaysMultiSelect: false,
});
private isHistoryStateChange = false;
private selectionBeforeOp: Id[] | null = null;
constructor() {
@ -371,12 +368,13 @@ class Editor extends BaseService {
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false / true
* @param options.doNotPushHistory false
* @returns
*/
public async add(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
@ -435,20 +433,24 @@ class Editor extends BaseService {
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
this.pushOpHistory(
'add',
{
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1];
}),
),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
if (!doNotPushHistory) {
this.pushOpHistory(
'add',
{
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1];
}),
),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
} else {
this.selectionBeforeOp = null;
}
}
this.emit('add', newNodes);
@ -538,10 +540,11 @@ class Editor extends BaseService {
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false / true
* @param options.doNotPushHistory false
*/
public async remove(
nodeOrNodeList: MNode | MNode[],
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp();
@ -569,7 +572,11 @@ class Editor extends BaseService {
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
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);
@ -650,34 +657,42 @@ class Editor extends BaseService {
*
* update后会触发依赖收集stage.update方法
* @param config id信息
* @param data
* @param data.changeRecords form
* @param data.doNotPushHistory false
* @returns
*/
public async update(
config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[] } = {},
data: { changeRecords?: ChangeRecord[]; doNotPushHistory?: boolean } = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const { doNotPushHistory = false } = data;
const nodes = Array.isArray(config) ? config : [config];
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
const curNodes = this.get('nodes');
if (!this.isHistoryStateChange && curNodes.length) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
})),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
if (curNodes.length) {
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
})),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
} else {
this.selectionBeforeOp = null;
}
}
this.isHistoryStateChange = false;
}
this.emit('update', updateData);
@ -691,9 +706,14 @@ class Editor extends BaseService {
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage DSL API
* @param options.doNotPushHistory false
* @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();
const root = this.get('root');
@ -712,7 +732,7 @@ class Editor extends BaseService {
parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
await this.update(parent);
await this.update(parent, { doNotPushHistory });
if (!doNotSelect) {
await this.select(node);
}
@ -759,12 +779,13 @@ class Editor extends BaseService {
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false true
* @param options.doNotPushHistory false
* @returns
*/
public async paste(
position: PastePosition = {},
collectorOptions?: TargetOptions,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<MNode | MNode[] | void> {
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return;
@ -785,7 +806,7 @@ class Editor extends BaseService {
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[]> {
@ -816,18 +837,19 @@ class Editor extends BaseService {
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage style DSL API
* @param options.doNotPushHistory false
* @returns
*/
public async alignCenter(
config: MNode | MNode[],
{ doNotSelect = false }: DslOpOptions = {},
{ doNotSelect = false, doNotPushHistory = false }: 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);
const newNode = await this.update(newNodes, { doNotPushHistory });
if (!doNotSelect) {
if (newNodes.length > 1) {
@ -843,8 +865,10 @@ class Editor extends BaseService {
/**
*
* @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();
const root = this.get('root');
@ -881,14 +905,18 @@ class Editor extends BaseService {
});
this.addModifiedNodeId(parent.id);
const pageForOp = this.getNodeInfo(node.id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(node.id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
} else {
this.selectionBeforeOp = null;
}
this.emit('move-layer', offset);
}
@ -905,11 +933,12 @@ class Editor extends BaseService {
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false true
* @param options.doNotPushHistory false
*/
public async moveToContainer(
config: MNode | MNode[],
targetId: Id,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
): Promise<MNode | MNode[]> {
const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
@ -935,10 +964,10 @@ class Editor extends BaseService {
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
const moves: MoveItem[] = [];
for (const cfg of configs) {
const { node, parent, page } = this.getNodeInfo(cfg.id, false);
for (const { id } of configs) {
const { node, parent, page } = this.getNodeInfo(id, false);
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) {
@ -948,96 +977,44 @@ class Editor extends BaseService {
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
const beforeSnapshots = new Map<Id, MNode>();
beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
for (const m of moves) {
if (!beforeSnapshots.has(m.parent.id)) {
beforeSnapshots.set(m.parent.id, cloneDeep(toRaw(m.parent)));
for (const { parent } of moves) {
if (!beforeSnapshots.has(parent.id)) {
beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
}
}
const layout = await this.getLayout(target);
const newConfigs: MNode[] = [];
let newConfigs: MNode[] = [];
for (const { cfg, node, parent } of moves) {
const index = getNodeIndex(node.id, parent);
parent.items?.splice(index, 1);
const moveNodes = moves.map(({ node }) => node);
await this.remove(moveNodes, { doNotPushHistory: true, doNotSelect, doNotSwitchPage: true });
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 (Array.isArray(srcValue)) {
return srcValue;
}
});
newConfig.style = getInitPositionStyle(newConfig.style, layout);
target.items.push(newConfig);
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);
}
if (!doNotPushHistory) {
// 整批只入栈一条历史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);
} else {
// 跳过选中目标节点(通常是因为目标位于其它页面),但 state.nodes 仍持有已经被
// 从源父容器中移除的旧节点引用 —— 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', []);
}
}
this.selectionBeforeOp = null;
}
// 整批只入栈一条历史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];
}
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();
if (!targetParent || !Array.isArray(targetParent.items)) return;
@ -1097,8 +1074,12 @@ class Editor extends BaseService {
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
}
}
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
if (!doNotPushHistory) {
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 });
}
@ -1127,14 +1108,14 @@ class Editor extends BaseService {
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'));
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 });
await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory });
}
public resetState() {
@ -1182,22 +1163,15 @@ class Editor extends BaseService {
}
private addModifiedNodeId(id: Id) {
if (!this.isHistoryStateChange) {
this.get('modifiedNodeIds').set(id, id);
}
this.get('modifiedNodeIds').set(id, id);
}
private captureSelectionBeforeOp() {
if (this.isHistoryStateChange || this.selectionBeforeOp) return;
if (this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
}
private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
if (this.isHistoryStateChange) {
this.selectionBeforeOp = null;
return;
}
const step: StepValue = {
data: pageData,
opType,
@ -1210,41 +1184,100 @@ class Editor extends BaseService {
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
historyService.push(step, pageData.id);
this.selectionBeforeOp = null;
this.isHistoryStateChange = false;
}
/**
* /
*
* DSL `editor.add / remove / update` `doNotPushHistory`
* `doNotSelect / doNotSwitchPage`
*
* add / remove / update "用户操作""撤销重做触发"
* `history-change`
*
* @param step
* @param reverse true = false =
*/
private async applyHistoryOp(step: StepValue, reverse: boolean) {
this.isHistoryStateChange = true;
const root = this.get('root');
const stage = this.get('stage');
if (!root) return;
const ctx: HistoryOpContext = {
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'),
};
const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const;
switch (step.opType) {
case 'add':
await applyHistoryAddOp(step, reverse, ctx);
case 'add': {
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;
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;
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;
}
}
this.set('modifiedNodeIds', step.modifiedNodeIds);
@ -1266,8 +1299,6 @@ class Editor extends BaseService {
}, 0);
this.emit('history-change', page as MPage | MPageFragment);
}
this.isHistoryStateChange = false;
}
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {

View File

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