fix(editor): 修复删除页面/页面片时画布被误清空的问题

删除当前页时先切换画布再通知 runtime 销毁;runtime 侧仅在删除当前渲染页时才调用 deletePage。
This commit is contained in:
roymondchen 2026-07-30 15:38:27 +08:00
parent c4b98e3257
commit aa42144738
6 changed files with 323 additions and 49 deletions

View File

@ -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({

View File

@ -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;
// 删除页面后默认会切到首个剩余页面selectDefaultdoNotSwitchPage 时跳过这次自动切换
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删除通知必须在卸载前发出
// 且不能 awaitruntime 未 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<MNode>[] = [];
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<MNode | MNode[]> {
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;
}

View File

@ -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<MNode | null> = [];
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', () => {

View File

@ -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);
}

View File

@ -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<TMagicApp>('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);
}

View File

@ -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();
});
});