From aa4214473858200c62b6efab79c0671e06cc3917 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 30 Jul 2026 15:38:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8D=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E9=A1=B5=E9=9D=A2/=E9=A1=B5=E9=9D=A2=E7=89=87?= =?UTF-8?q?=E6=97=B6=E7=94=BB=E5=B8=83=E8=A2=AB=E8=AF=AF=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除当前页时先切换画布再通知 runtime 销毁;runtime 侧仅在删除当前渲染页时才调用 deletePage。 --- packages/core/tests/App.spec.ts | 14 ++ packages/editor/src/services/editor.ts | 93 ++++++----- .../editor/tests/unit/services/editor.spec.ts | 149 +++++++++++++++++- runtime/tmagic-form/src/useFormConfig.ts | 9 +- .../src/hooks/use-editor-dsl.ts | 12 +- .../tests/use-editor-dsl.test.ts | 95 +++++++++++ 6 files changed, 323 insertions(+), 49 deletions(-) create mode 100644 runtime/vue-runtime-help/tests/use-editor-dsl.test.ts diff --git a/packages/core/tests/App.spec.ts b/packages/core/tests/App.spec.ts index 14d61819..e3a5d406 100644 --- a/packages/core/tests/App.spec.ts +++ b/packages/core/tests/App.spec.ts @@ -341,6 +341,20 @@ describe('App 配置/方法/组件注册', () => { expect(app.page).toBeUndefined(); }); + test('deletePage 销毁当前 page', () => { + const app = new App({ + config: { + type: NodeType.ROOT, + id: 'app', + items: [{ type: NodeType.PAGE, id: 'p1', items: [] }], + }, + }); + expect(app.page?.data.id).toBe('p1'); + + app.deletePage(); + expect(app.page).toBeUndefined(); + }); + test('runCode 执行代码块', async () => { const fn = vi.fn(); const app = new App({ diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 481f2961..a3d0a3ef 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import { reactive, toRaw } from 'vue'; +import { nextTick, reactive, toRaw } from 'vue'; import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es'; import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core'; @@ -30,6 +30,7 @@ import { guid, isPage, isPageFragment, + isPageOrFragment, setValueByKeyPath, traverseNode, } from '@tmagic/utils'; @@ -272,7 +273,7 @@ class Editor extends BaseService { public isOnDifferentPage(node: MNode): boolean { const currentPageId = this.get('page')?.id; if (currentPageId === undefined || currentPageId === null) return false; - if (isPage(node) || isPageFragment(node)) { + if (isPageOrFragment(node)) { return `${node.id}` !== `${currentPageId}`; } const nodePage = this.getNodeInfo(node.id, false).page; @@ -413,11 +414,11 @@ class Editor extends BaseService { if (!curNode) throw new Error('当前选中节点为空'); - if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !(isPage(node) || isPageFragment(node))) { + if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) { throw new Error('app下不能添加组件'); } - if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) { + if (parent.id !== curNode.id && !isPageOrFragment(node)) { const index = parent.items.indexOf(curNode); parent.items?.splice(index + 1, 0, node); } else { @@ -487,7 +488,7 @@ class Editor extends BaseService { const newNodes = await Promise.all( addNodes.map((node) => { const root = this.get('root'); - if ((isPage(node) || isPageFragment(node)) && root) { + if (isPageOrFragment(node) && root) { return this.doAdd(node, root); } const parentNode = parent ?? getAddParent(node); @@ -523,7 +524,7 @@ class Editor extends BaseService { } } - if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) { + if (!isPageOrFragment(newNodes[0])) { const pageForOp = this.getNodeInfo(newNodes[0].id, false).page; if (!doNotPushHistory) { const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id; @@ -554,8 +555,8 @@ class Editor extends BaseService { doNotPushHistory, }); - // 页面 / 页面片新增不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知 - const addedPages = newNodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[]; + // 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知 + const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[]; if (addedPages.length) { historyService.notifyPageStructureChange({ add: addedPages, remove: [] }); } @@ -578,12 +579,14 @@ class Editor extends BaseService { if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点'); - parent.items?.splice(index, 1); const stage = this.get('stage'); - stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) }); + const currentPage = this.get('page'); + const isDeletingCurrentPage = isPageOrFragment(node) && !!currentPage && `${currentPage.id}` === `${node.id}`; + + parent.items?.splice(index, 1); // 始终清理已删除节点在 state 中的残留引用: - // - 即使后续会调用 selectDefault / select(parent) 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点 + // - 即使后续会调用 select 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点 const selectedNodes = this.get('nodes'); const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`); if (removedSelectedIndex !== -1) { @@ -591,38 +594,44 @@ class Editor extends BaseService { nextSelected.splice(removedSelectedIndex, 1); this.set('nodes', nextSelected); } - if (isPage(node) || isPageFragment(node)) { - const currentPage = this.get('page'); - if (currentPage && `${currentPage.id}` === `${node.id}`) { - this.set('page', null); - } - } - - const selectDefault = async (pages: MNode[]) => { - if (pages[0]) { - await this.select(pages[0]); - stage?.select(pages[0].id); - } else { - this.selectRoot(); - } - }; + const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) }; const rootItems = root.items || []; + // 删非当前页时画布不应该变动,所以只有删的是当前页才重新选中 + const shouldReselect = isDeletingCurrentPage && !doNotSelect && !doNotSwitchPage; + // 还有剩余页面才能切过去,否则退回选中 root + const shouldSwitchPage = shouldReselect && rootItems.length > 0; - if (isPage(node)) { - this.state.pageLength -= 1; - - // 删除页面后默认会切到首个剩余页面(selectDefault),doNotSwitchPage 时跳过这次自动切换 - if (!doNotSelect && !doNotSwitchPage) { - await selectDefault(rootItems); + if (isPageOrFragment(node)) { + if (isPage(node)) { + this.state.pageLength -= 1; + } else { + this.state.pageFragmentLength -= 1; } - } else if (isPageFragment(node)) { - this.state.pageFragmentLength -= 1; - if (!doNotSelect && !doNotSwitchPage) { - await selectDefault(rootItems); + if (shouldSwitchPage) { + // runtime 收到页面删除时会销毁当前渲染的 page 实例,必须先把画布切到剩余页面再通知删除, + // 否则画布会被清空,且要等下一次 updatePageId 才能恢复。 + // nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。 + await this.select(rootItems[0]); + stage?.select(rootItems[0].id); + await nextTick(); + stage?.remove(removeData); + } else { + // page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出, + // 且不能 await:runtime 未 ready 时 getRuntime 的监听会随 destroy 一起被移除,永远不会 resolve + stage?.remove(removeData); + + if (shouldReselect) { + // 页面已全部删完 + this.selectRoot(); + } else if (isDeletingCurrentPage) { + this.set('page', null); + } } } else { + stage?.remove(removeData); + if (!doNotSelect) { await this.select(parent); stage?.select(parent.id); @@ -664,7 +673,7 @@ class Editor extends BaseService { const removedItems: StepDiffItem[] = []; let pageForOp: { name: string; id: Id } | null = null; - if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) { + if (!isPageOrFragment(nodes[0])) { for (const n of nodes) { const { parent, node: curNode, page } = this.getNodeInfo(n.id, false); if (parent && curNode) { @@ -702,8 +711,8 @@ class Editor extends BaseService { this.emit('remove', nodes); this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory }); - // 页面 / 页面片删除不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知 - const removedPages = nodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[]; + // 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知 + const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[]; if (removedPages.length) { historyService.notifyPageStructureChange({ add: [], remove: removedPages }); } @@ -764,7 +773,7 @@ class Editor extends BaseService { } // 只有被更新节点正好是当前选中页面时才同步 state.page,避免「更新非当前页」误将编辑器切到该页 - if (isPage(newConfig) || isPageFragment(newConfig)) { + if (isPageOrFragment(newConfig)) { const currentPage = this.get('page'); if (currentPage && `${currentPage.id}` === `${newConfig.id}`) { this.set('page', newConfig as MPage | MPageFragment); @@ -1139,7 +1148,7 @@ class Editor extends BaseService { }: DslOpOptions = {}, ): Promise { const isBatch = Array.isArray(config); - const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item))); + const configs = (isBatch ? config : [config]).filter((item) => !isPageOrFragment(item)); if (configs.length === 0) { throw new Error('没有可移动的节点'); @@ -1760,7 +1769,7 @@ class Editor extends BaseService { private getPageOfNode(id: Id): MPage | MPageFragment | null { const { node, page } = this.getNodeInfo(id, false); if (page) return page; - if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment; + if (node && isPageOrFragment(node)) return node as MPage | MPageFragment; return null; } diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index e7a24a0d..988bc895 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { cloneDeep } from 'lodash-es'; import type { MApp, MContainer, MNode } from '@tmagic/core'; @@ -552,6 +552,153 @@ describe('remove', () => { // 自动切到剩余首个页面 expect(editorService.get('page')?.id).toBe(addedId); }); + + test('默认删除当前页面并自动切页时,不会先把 page 置为 null', async () => { + editorService.set('root', cloneDeep(root)); + const rootNode = editorService.get('root'); + await editorService.select(NodeId.PAGE_ID); + const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode); + const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id; + + await editorService.select(NodeId.PAGE_ID); + + const pageValues: Array = []; + const originalSet = editorService.set.bind(editorService); + const setSpy = vi.spyOn(editorService, 'set').mockImplementation((name, value, options) => { + if (name === 'page') { + pageValues.push(value as MNode | null); + } + return originalSet(name, value, options); + }); + + try { + await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE }); + } finally { + setSpy.mockRestore(); + } + + expect(pageValues.some((page) => page === null)).toBe(false); + expect(editorService.get('page')?.id).toBe(addedId); + }); + + test('删除非当前页面时保持当前页选中,不强制切到首个页面', async () => { + editorService.set('root', cloneDeep(root)); + const rootNode = editorService.get('root'); + await editorService.select(NodeId.PAGE_ID); + const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode); + const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id; + + await editorService.select(NodeId.PAGE_ID); + expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID); + + await editorService.remove({ id: addedId, type: NodeType.PAGE }); + + expect(editorService.getNodeById(addedId)).toBeNull(); + expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID); + }); + + test('删除最后一个页面后退回选中 root', async () => { + editorService.set('root', cloneDeep(root)); + const rootNode = editorService.get('root'); + await editorService.select(NodeId.PAGE_ID); + expect(rootNode?.items.length).toBe(1); + + await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE }); + + expect(rootNode?.items.length).toBe(0); + expect(editorService.get('page')).toBeNull(); + // root 下已无页面,编辑器应回到「选中 root」状态,而不是什么都没选中 + expect(editorService.get('nodes')).toEqual([rootNode]); + expect(editorService.get('parent')).toBeNull(); + }); + + describe('与画布的调用顺序', () => { + const createStageStub = () => { + const calls: string[] = []; + return { + calls, + stage: { + remove: vi.fn(() => { + calls.push('remove'); + return Promise.resolve(); + }), + select: vi.fn(() => { + calls.push('select'); + return Promise.resolve(); + }), + }, + }; + }; + + afterEach(() => { + editorService.set('stage', null); + }); + + test('删除当前页面时先把画布切到剩余页面,再通知画布删除', async () => { + editorService.set('root', cloneDeep(root)); + const rootNode = editorService.get('root'); + await editorService.select(NodeId.PAGE_ID); + const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode); + const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id; + await editorService.select(NodeId.PAGE_ID); + + const { calls, stage } = createStageStub(); + editorService.set('stage', stage as any); + + await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE }); + + // runtime 删除 page 时会销毁当前渲染的实例,必须切页在前,否则画布会被清空 + expect(calls).toEqual(['select', 'remove']); + expect(stage.select).toHaveBeenCalledWith(addedId); + expect(editorService.get('page')?.id).toBe(addedId); + }); + + test('删除当前页面片时同样先切页再通知画布删除', async () => { + editorService.set('root', cloneDeep(root)); + const rootNode = editorService.get('root'); + await editorService.select(NodeId.PAGE_ID); + const fragment = await editorService.add({ type: NodeType.PAGE_FRAGMENT }, rootNode); + const fragmentId = Array.isArray(fragment) ? fragment[0].id : fragment.id; + // 新增页面片后当前页已切到页面片 + expect(editorService.get('page')?.id).toBe(fragmentId); + + const { calls, stage } = createStageStub(); + editorService.set('stage', stage as any); + + await editorService.remove({ id: fragmentId, type: NodeType.PAGE_FRAGMENT }); + + expect(calls).toEqual(['select', 'remove']); + expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID); + }); + + test('删除普通节点时先通知画布删除,再选中父节点', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.NODE_ID); + + const { calls, stage } = createStageStub(); + editorService.set('stage', stage as any); + + await editorService.remove({ id: NodeId.NODE_ID, type: 'text' }); + + expect(calls).toEqual(['remove', 'select']); + expect(stage.select).toHaveBeenCalledWith(NodeId.PAGE_ID); + }); + + test('删除最后一个页面时在 Stage 卸载前发出删除通知', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.PAGE_ID); + + const { calls, stage } = createStageStub(); + editorService.set('stage', stage as any); + + await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE }); + + // page 置空会让 Workspace 卸载 Stage,删除通知必须在这之前发出 + expect(calls).toEqual(['remove']); + // selectRoot 会顺带清掉 stage 引用 + expect(editorService.get('stage')).toBeNull(); + }); + }); }); describe('update', () => { diff --git a/runtime/tmagic-form/src/useFormConfig.ts b/runtime/tmagic-form/src/useFormConfig.ts index 353948c2..51aea637 100644 --- a/runtime/tmagic-form/src/useFormConfig.ts +++ b/runtime/tmagic-form/src/useFormConfig.ts @@ -1,7 +1,7 @@ import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue'; import type { Id, MApp, MNode } from '@tmagic/core'; -import TMagicApp from '@tmagic/core'; +import TMagicApp, { NodeType } from '@tmagic/core'; import type { FormConfig, MForm, RemoveData, UpdateData } from '@tmagic/editor'; import { getElById, getNodePath, initValue, replaceChildNode } from '@tmagic/editor'; @@ -136,8 +136,11 @@ export const useFormConfig = (props: AppProps) => { const parent = getNodePath(parentId, [root.value]).pop(); if (!parent) throw new Error('未找到父元素'); - if (node.type === 'page') { - app?.deletePage(); + // 页面与页面片都由 app.page 承载,只有删的正是当前渲染的那个才销毁实例,避免误清其它页的画布 + if (node.type === NodeType.PAGE || node.type === NodeType.PAGE_FRAGMENT) { + if (`${app?.page?.data.id}` === `${node.id}`) { + app?.deletePage(); + } } else { app?.page?.deleteNode(node.id); } diff --git a/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts b/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts index 927e5646..7d590a61 100644 --- a/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts +++ b/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts @@ -2,7 +2,7 @@ import { computed, inject, nextTick, reactive, ref, watch } from 'vue'; import type TMagicApp from '@tmagic/core'; import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core'; -import { asyncLoadCss, getElById, getNodePath, replaceChildNode } from '@tmagic/core'; +import { asyncLoadCss, getElById, getNodePath, NodeType, replaceChildNode } from '@tmagic/core'; import type { Magic, RemoveData, Runtime, UpdateData } from '@tmagic/stage'; declare global { @@ -11,6 +11,8 @@ declare global { } } +const isPageNode = (node: MNode) => node.type === NodeType.PAGE || node.type === NodeType.PAGE_FRAGMENT; + let styleEl: HTMLStyleElement | null = null; const createCss = async (config: MPage | MPageFragment) => { @@ -153,8 +155,12 @@ export const useEditorDsl = (app = inject('app'), runtimeApi: Runtime const parent = getNodePath(parentId, [root.value]).pop(); if (!parent) throw new Error('未找到父元素'); - if (node.type === 'page') { - app?.deletePage(); + // 页面与页面片都由 app.page 承载,只有删的正是当前渲染的那个才销毁实例: + // 无条件销毁会清空画布,且编辑器删页时已先切到其它页,会把刚切过去的页面误清掉 + if (isPageNode(node)) { + if (`${app?.page?.data.id}` === `${node.id}`) { + app?.deletePage(); + } } else { app?.page?.deleteNode(node.id); } diff --git a/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts b/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts new file mode 100644 index 00000000..d5b90ca5 --- /dev/null +++ b/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, test } from 'vitest'; + +import type { MApp } from '@tmagic/core'; +import Core, { NodeType } from '@tmagic/core'; +import type { Runtime } from '@tmagic/stage'; + +import { useEditorDsl } from '../src'; + +const createDsl = () => + ({ + type: NodeType.ROOT, + id: 'app', + items: [ + { type: NodeType.PAGE, id: 'p1', items: [{ id: 'btn', type: 'button' }] }, + { type: NodeType.PAGE, id: 'p2', items: [] }, + { type: NodeType.PAGE_FRAGMENT, id: 'f1', items: [] }, + ], + }) as unknown as MApp; + +const setup = (curPageId?: string) => { + const app = new Core({}); + let runtime: Runtime = {}; + + (window as any).magic = { + onRuntimeReady: (rt: Runtime) => { + runtime = rt; + }, + }; + + useEditorDsl(app); + + const dsl = createDsl(); + runtime.updateRootConfig?.(dsl); + if (curPageId) { + runtime.updatePageId?.(curPageId); + } + + return { app, runtime, dsl }; +}; + +describe('useEditorDsl remove', () => { + beforeEach(() => { + (window as any).magic = undefined; + }); + + test('删除当前渲染的页面时销毁 page 实例', () => { + const { app, runtime, dsl } = setup(); + expect(app.page?.data.id).toBe('p1'); + + runtime.remove?.({ id: 'p1', parentId: 'app', root: dsl }); + + expect(app.page).toBeUndefined(); + expect(dsl.items.some((item) => item.id === 'p1')).toBe(false); + }); + + test('删除非当前页面时保留当前 page 实例,不清空画布', () => { + const { app, runtime, dsl } = setup(); + expect(app.page?.data.id).toBe('p1'); + + runtime.remove?.({ id: 'p2', parentId: 'app', root: dsl }); + + expect(app.page?.data.id).toBe('p1'); + expect(dsl.items.some((item) => item.id === 'p2')).toBe(false); + }); + + test('删除当前渲染的页面片时同样销毁 page 实例', () => { + const { app, runtime, dsl } = setup('f1'); + expect(app.page?.data.id).toBe('f1'); + + runtime.remove?.({ id: 'f1', parentId: 'app', root: dsl }); + + expect(app.page).toBeUndefined(); + expect(dsl.items.some((item) => item.id === 'f1')).toBe(false); + }); + + test('删除非当前页面片时保留当前 page 实例', () => { + const { app, runtime, dsl } = setup(); + expect(app.page?.data.id).toBe('p1'); + + runtime.remove?.({ id: 'f1', parentId: 'app', root: dsl }); + + expect(app.page?.data.id).toBe('p1'); + expect(dsl.items.some((item) => item.id === 'f1')).toBe(false); + }); + + test('删除普通节点时只从当前 page 移除该节点', () => { + const { app, runtime, dsl } = setup(); + expect(app.getNode('btn')?.data.id).toBe('btn'); + + runtime.remove?.({ id: 'btn', parentId: 'p1', root: dsl }); + + expect(app.page?.data.id).toBe('p1'); + expect(app.getNode('btn')).toBeUndefined(); + }); +});