diff --git a/docs/api/editor/codeBlockServiceMethods.md b/docs/api/editor/codeBlockServiceMethods.md index 8af093f4..592aa97a 100644 --- a/docs/api/editor/codeBlockServiceMethods.md +++ b/docs/api/editor/codeBlockServiceMethods.md @@ -48,6 +48,8 @@ - **参数:** - `{string | number}` id 代码块id - {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息 + - `{Object}` options 可选配置 + - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) - **返回:** - `{Promise}` @@ -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}` @@ -209,7 +216,7 @@ ::: tip 对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条 - `newContent=null` 的删除记录;不存在的 id 不会入历史。 + `newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。 ::: ## setParamsColConfig diff --git a/docs/api/editor/dataSourceServiceMethods.md b/docs/api/editor/dataSourceServiceMethods.md index d5f5359a..bf5c2e4d 100644 --- a/docs/api/editor/dataSourceServiceMethods.md +++ b/docs/api/editor/dataSourceServiceMethods.md @@ -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` 也可显式跳过写入历史栈。 ::: - **示例:** diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index 2789fe89..a3f2165f 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -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}` @@ -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}` @@ -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}` @@ -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}` @@ -684,6 +695,8 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 - **参数:** - `{number}` left - `{number}` top + - `{Object}` options 可选配置 + - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) - **返回:** - `{Promise}` diff --git a/packages/editor/src/layouts/sidebar/layer/use-node-status.ts b/packages/editor/src/layouts/sidebar/layer/use-node-status.ts index 5669fa4b..0bd6c947 100644 --- a/packages/editor/src/layouts/sidebar/layer/use-node-status.ts +++ b/packages/editor/src/layouts/sidebar/layer/use-node-status.ts @@ -9,12 +9,15 @@ import { updateStatus } from '@editor/utils/tree'; const createPageNodeStatus = (page: MPage | MPageFragment, initialLayerNodeStatus?: Map) => { const map = new Map(); - 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(node, (node) => { diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index 3fb88329..4327344f 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -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): Promise { - this.setCodeDslByIdSync(id, codeConfig, true); + public async setCodeDslById( + id: Id, + codeConfig: Partial, + { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}, + ): Promise { + 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, force = true): void { + public setCodeDslByIdSync( + id: Id, + codeConfig: Partial, + 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 { + public async deleteCodeDslByIds( + codeIds: Id[], + { doNotPushHistory = false }: { doNotPushHistory?: boolean } = {}, + ): Promise { 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 }); } diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 4907d95e..45269320 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -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 }); } diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 52170c60..e9b4772d 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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 { 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 { 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 { 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 { + public async sort( + id1: Id, + id2: Id, + { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {}, + ): Promise { 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 { 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 { @@ -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 { 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 { + public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise { 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 { 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(); 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, 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 { diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 133bd591..485a4174 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -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; }; diff --git a/packages/editor/src/utils/editor-history.ts b/packages/editor/src/utils/editor-history.ts deleted file mode 100644 index 45ad80e6..00000000 --- a/packages/editor/src/utils/editor-history.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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)), - }); - } -} diff --git a/packages/editor/tests/unit/services/dragto_repro.spec.ts b/packages/editor/tests/unit/services/dragto_repro.spec.ts new file mode 100644 index 00000000..b4d7029f --- /dev/null +++ b/packages/editor/tests/unit/services/dragto_repro.spec.ts @@ -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 = {}; + 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); + }); +}); diff --git a/packages/editor/tests/unit/utils/editor-history.spec.ts b/packages/editor/tests/unit/utils/editor-history.spec.ts deleted file mode 100644 index 3891f237..00000000 --- a/packages/editor/tests/unit/utils/editor-history.spec.ts +++ /dev/null @@ -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(); - }); -});