mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
feat(editor): 写操作支持 doNotPushHistory 选项以跳过历史记录
- editor/codeBlock/dataSource 的 add/update/delete 等接口新增 doNotPushHistory 选项 - 移除不再使用的 editor-history 工具及其单测 - 修复 layer 节点状态在重建时丢失已有 status 的问题 - 同步更新 service 方法文档,新增 dragto 复现用例
This commit is contained in:
parent
e2c065f90d
commit
4c855ba50b
@ -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
|
||||
|
||||
@ -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` 也可显式跳过写入历史栈。
|
||||
:::
|
||||
|
||||
- **示例:**
|
||||
|
||||
@ -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>}`
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
110
packages/editor/tests/unit/services/dragto_repro.spec.ts
Normal file
110
packages/editor/tests/unit/services/dragto_repro.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user