feat(editor): 新增 DSL 修改方法的 doNotSwitchPage 选项

在 add / remove / doRemove / sort / paste / alignCenter / moveToContainer
的 options 对象中新增 doNotSwitchPage,与 doNotSelect 合并为同一配置 DslOpOptions,
用于在 DSL 操作(新增 / 删除 / 跨页移动)会引发当前页面切换时跳过该次切换。

- 抽取共用类型 DslOpOptions 到 type.ts 并对外导出
- 新增 editorService.isOnDifferentPage 辅助方法用于跨页判断
- 修复 doUpdate 同步 state.page 时无条件覆盖的问题:只在被更新页就是当前页时才同步引用,避免「更新非当前页」误把编辑器切到该页
- doRemove 中对已删除节点的引用清理与当前页清空逻辑提升为无条件执行,避免 doNotSelect / doNotSwitchPage 跳过后续 select 时 state 持有已删除节点
- 补充对应单元测试与 API 文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-22 16:49:52 +08:00
parent eb1c5a3ec1
commit 3d038513e3
4 changed files with 259 additions and 43 deletions

View File

@ -170,6 +170,29 @@ const parent = editorService.getParentById("text_123");
console.log(parent);
```
## isOnDifferentPage
- **参数:**
- {`MNode`} node 节点配置
- **返回:**
- `{boolean}` true 表示该节点位于非当前页面(即选中该节点将会引起当前页面切换)
- **详情:**
判断给定节点是否位于非当前页面,通常用于配合 `doNotSwitchPage` 选项判断 DSL 操作是否会引起页面切换
- **示例:**
```js
import { editorService } from "@tmagic/editor";
const otherPageNode = editorService.getNodeById("text_456");
if (editorService.isOnDifferentPage(otherPageNode)) {
console.log("该节点在其它页面,操作会触发页面切换");
}
```
## getLayout
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是
@ -334,6 +357,7 @@ editorService.highlight("text_123");
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
@ -357,6 +381,7 @@ editorService.highlight("text_123");
- {`MNode`} node 要删除的节点
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
- **返回:**
- `{Promise<void>}`
@ -365,6 +390,10 @@ editorService.highlight("text_123");
删除指定的组件或者页面
:::tip
无论是否传入 `doNotSelect` / `doNotSwitchPage`当被删除节点在当前选中列表中时state 都会自动移除该节点的引用当被删除的正好是当前页面时state.page 也会同步清空,避免持有已删除节点
:::
## remove
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是
@ -373,6 +402,7 @@ editorService.highlight("text_123");
- {`MNode` | `MNode`[])} node 要删除的节点或节点集合
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
- **返回:**
- `{Promise<void>}`
@ -413,6 +443,8 @@ editorService.highlight("text_123");
节点中应该要有id不然不知道要更新哪个节点
当被更新节点正好在当前选中列表中时state 会自动同步到新的节点引用,无需调用方处理
当被更新节点正好是当前页面时state.page 也会同步到新的节点引用;更新非当前页面(不同 ID时不会把编辑器切到该页
:::
## update
@ -448,6 +480,7 @@ editorService.highlight("text_123");
- `{ string | number }` id2
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
- **返回:**
- `{Promise<void>}`
@ -514,6 +547,7 @@ editorService.highlight("text_123");
- `{TargetOptions}` collectorOptions 可选的依赖收集器配置
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false跨页粘贴时为 true 会跳过页面切换)
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
@ -550,6 +584,7 @@ editorService.highlight("text_123");
- {`MNode` | `MNode`[]} config 需要居中的组件或者组件集合
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style方法内为空操作保留以与其它 DSL 操作 API 一致)
- **返回:**
- {Promise<`MNode` | `MNode`[]>}
@ -589,6 +624,7 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{string | number}` targetId 容器ID
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
- **返回:**
- Promise<`MNode` | undefined>

View File

@ -33,6 +33,7 @@ import type {
AddMNode,
AsyncHookPlugin,
AsyncMethodName,
DslOpOptions,
EditorEvents,
EditorNodeInfo,
HistoryOpType,
@ -191,6 +192,22 @@ class Editor extends BaseService {
return parent;
}
/**
*
* @param node
* @returns true
*/
public isOnDifferentPage(node: MNode): boolean {
const currentPageId = this.get('page')?.id;
if (currentPageId === undefined || currentPageId === null) return false;
if (isPage(node) || isPageFragment(node)) {
return `${node.id}` !== `${currentPageId}`;
}
const nodePage = this.getNodeInfo(node.id, false).page;
if (!nodePage) return false;
return `${nodePage.id}` !== `${currentPageId}`;
}
/**
*
*/
@ -370,15 +387,16 @@ class Editor extends BaseService {
* @param parent
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false / true
* @returns
*/
public async add(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
options?: { doNotSelect?: boolean },
options?: DslOpOptions,
): Promise<MNode | MNode[]> {
const safeParentNode = safeParent(parent);
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
this.captureSelectionBeforeOp();
@ -409,14 +427,19 @@ class Editor extends BaseService {
);
if (newNodes.length > 1) {
if (!doNotSelect) {
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
const wouldSwitchPage = newNodes.some((n) => this.isOnDifferentPage(n));
if (!doNotSelect && !(doNotSwitchPage && wouldSwitchPage)) {
const newNodeIds = newNodes.map((node) => node.id);
// 触发选中样式
stage?.multiSelect(newNodeIds);
await this.multiSelect(newNodeIds);
}
} else {
if (!doNotSelect) {
const wouldSwitchPage = this.isOnDifferentPage(newNodes[0]);
const skipSelect = doNotSelect || (doNotSwitchPage && wouldSwitchPage);
if (!skipSelect) {
await this.select(newNodes[0]);
}
@ -424,7 +447,7 @@ class Editor extends BaseService {
this.state.pageLength += 1;
} else if (isPageFragment(newNodes[0])) {
this.state.pageFragmentLength += 1;
} else if (!doNotSelect) {
} else if (!skipSelect) {
// 新增页面这个时候页面还有渲染出来此时select会出错在runtime-ready的时候回去select
stage?.select(newNodes[0].id);
}
@ -453,8 +476,8 @@ class Editor extends BaseService {
return Array.isArray(addNode) ? newNodes : newNodes[0];
}
public async doRemove(node: MNode, options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
public async doRemove(node: MNode, options?: DslOpOptions): Promise<void> {
const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
const root = this.get('root');
if (!root) throw new Error('root不能为空');
@ -471,21 +494,19 @@ class Editor extends BaseService {
const stage = this.get('stage');
stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
if (doNotSelect) {
// 当被删除节点正好在当前选中列表中时,必须从 state 中移除引用,避免 state 持有已删除节点(与 doNotSelect 无关)
const selectedNodes = this.get('nodes');
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
if (removedSelectedIndex !== -1) {
const nextSelected = [...selectedNodes];
nextSelected.splice(removedSelectedIndex, 1);
this.set('nodes', nextSelected);
}
// 同理,如果被删除的是当前 page也清空 state.page避免持有已删除页面
if (isPage(node) || isPageFragment(node)) {
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${node.id}`) {
this.set('page', null);
}
// 始终清理已删除节点在 state 中的残留引用:
// - 即使后续会调用 selectDefault / select(parent) 覆盖跳过这些调用doNotSelect / doNotSwitchPage时也不能让 state 持有已删除节点
const selectedNodes = this.get('nodes');
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
if (removedSelectedIndex !== -1) {
const nextSelected = [...selectedNodes];
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);
}
}
@ -505,13 +526,14 @@ class Editor extends BaseService {
if (isPage(node)) {
this.state.pageLength -= 1;
if (!doNotSelect) {
// 删除页面后默认会切到首个剩余页面selectDefaultdoNotSwitchPage 时跳过这次自动切换
if (!doNotSelect && !doNotSwitchPage) {
await selectDefault(rootItems);
}
} else if (isPageFragment(node)) {
this.state.pageFragmentLength -= 1;
if (!doNotSelect) {
if (!doNotSelect && !doNotSwitchPage) {
await selectDefault(rootItems);
}
} else {
@ -534,9 +556,10 @@ class Editor extends BaseService {
* @param {Object} node
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false / true
*/
public async remove(nodeOrNodeList: MNode | MNode[], options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
public async remove(nodeOrNodeList: MNode | MNode[], options?: DslOpOptions): Promise<void> {
const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
this.captureSelectionBeforeOp();
@ -561,7 +584,7 @@ class Editor extends BaseService {
}
}
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect })));
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
if (removedItems.length > 0 && pageForOp) {
this.pushOpHistory('remove', { removedItems }, pageForOp);
@ -624,8 +647,12 @@ class Editor extends BaseService {
this.set('nodes', [...selectedNodes]);
}
// 只有被更新节点正好是当前选中页面时才同步 state.page避免「更新非当前页」误将编辑器切到该页
if (isPage(newConfig) || isPageFragment(newConfig)) {
this.set('page', newConfig as MPage | MPageFragment);
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
this.set('page', newConfig as MPage | MPageFragment);
}
}
this.addModifiedNodeId(newConfig.id);
@ -681,10 +708,11 @@ class Editor extends BaseService {
* @param id2 ID
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage DSL API
* @returns void
*/
public async sort(id1: Id, id2: Id, options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
public async sort(id1: Id, id2: Id, options?: DslOpOptions): Promise<void> {
const { doNotSelect = false } = safeOptions<DslOpOptions>(options);
this.captureSelectionBeforeOp();
@ -750,14 +778,15 @@ class Editor extends BaseService {
* @param collectorOptions
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false true
* @returns
*/
public async paste(
position: PastePosition = {},
collectorOptions?: TargetOptions,
options?: { doNotSelect?: boolean },
options?: DslOpOptions,
): Promise<MNode | MNode[] | void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return;
@ -778,7 +807,7 @@ class Editor extends BaseService {
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
}
return this.add(pasteConfigs, parent, { doNotSelect });
return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage });
}
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@ -808,10 +837,11 @@ class Editor extends BaseService {
* @param config
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage style DSL API
* @returns
*/
public async alignCenter(config: MNode | MNode[], options?: { doNotSelect?: boolean }): Promise<MNode | MNode[]> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
public async alignCenter(config: MNode | MNode[], options?: DslOpOptions): Promise<MNode | MNode[]> {
const { doNotSelect = false } = safeOptions<DslOpOptions>(options);
const nodes = Array.isArray(config) ? config : [config];
const stage = this.get('stage');
@ -890,13 +920,10 @@ class Editor extends BaseService {
* @param targetId ID
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false true
*/
public async moveToContainer(
config: MNode,
targetId: Id,
options?: { doNotSelect?: boolean },
): Promise<MNode | undefined> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
public async moveToContainer(config: MNode, targetId: Id, options?: DslOpOptions): Promise<MNode | undefined> {
const { doNotSelect = false, doNotSwitchPage = false } = safeOptions<DslOpOptions>(options);
this.captureSelectionBeforeOp();
@ -925,7 +952,11 @@ class Editor extends BaseService {
target.items.push(newConfig);
if (!doNotSelect) {
// 目标容器是否在非当前页面:选中目标会触发当前页面切换
const targetWouldSwitchPage = this.isOnDifferentPage(target);
const skipSelect = doNotSelect || (doNotSwitchPage && targetWouldSwitchPage);
if (!skipSelect) {
await stage.select(targetId);
}
@ -936,7 +967,7 @@ class Editor extends BaseService {
root: cloneDeep(root),
});
if (!doNotSelect) {
if (!skipSelect) {
await this.select(newConfig);
stage.select(newConfig.id);
}

View File

@ -873,3 +873,13 @@ export const canUsePluginMethods = {
};
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
/**
* DSL
* - doNotSelect: 操作后是否不要自动触发选中 this.select / this.multiSelect / stage.select / stage.multiSelect
* - doNotSwitchPage: 操作若会引发当前页面切换 / /
*/
export type DslOpOptions = {
doNotSelect?: boolean;
doNotSwitchPage?: boolean;
};

View File

@ -190,6 +190,43 @@ describe('getParentById', () => {
});
});
describe('isOnDifferentPage', () => {
test('当前未选中任何页面时返回 false', () => {
editorService.set('root', cloneDeep(root));
editorService.resetState();
const pageNode = editorService.getNodeById(NodeId.PAGE_ID)!;
expect(editorService.isOnDifferentPage(pageNode)).toBe(false);
});
test('节点在当前页面内时返回 false', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const innerNode = editorService.getNodeById(NodeId.NODE_ID)!;
expect(editorService.isOnDifferentPage(innerNode)).toBe(false);
});
test('页面节点本身就是当前页面时返回 false', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const pageNode = editorService.getNodeById(NodeId.PAGE_ID)!;
expect(editorService.isOnDifferentPage(pageNode)).toBe(false);
});
test('节点位于非当前页面时返回 true', 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, { doNotSwitchPage: true });
const newPageId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
// 当前还在第一个页面
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
const newPageNode = editorService.getNodeById(newPageId)!;
expect(editorService.isOnDifferentPage(newPageNode)).toBe(true);
});
});
describe('select', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
@ -303,6 +340,24 @@ describe('add', () => {
// 但当前选中节点保持原状(未自动选中新增节点)
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
test('doNotSwitchPage: true 新增页面时保持当前页面不切换', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const beforePageId = editorService.get('page')?.id;
expect(beforePageId).toBe(NodeId.PAGE_ID);
const rootNode = editorService.get('root');
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode, { doNotSwitchPage: true });
// 新页面已加入 dsl
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
expect(rootNode?.items.length).toBe(2);
// 当前 page 保持不变(没有自动切到新加的页面)
expect(editorService.get('page')?.id).toBe(beforePageId);
});
});
describe('remove', () => {
@ -363,6 +418,51 @@ describe('remove', () => {
// state.nodes 中不再包含被删除的节点
expect(editorService.get('nodes').some((n) => n.id === NodeId.NODE_ID)).toBe(false);
});
test('doNotSwitchPage: true 删除当前页面后不自动切到其它页面', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
// 先加一个页面,确保 root 下有 2 个页面
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
expect(rootNode?.items.length).toBe(2);
// 选中第一个页面,作为当前页面
await editorService.select(NodeId.PAGE_ID);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
// 删除当前页面,并要求不切换页面
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE }, { doNotSwitchPage: true });
// 被删除页面在 dsl 中确实已不存在
expect(editorService.getNodeById(NodeId.PAGE_ID)).toBeNull();
// 当前 page 引用被清空,不会被自动切到剩余页面
expect(editorService.get('page')).toBeNull();
// 仍保留 newPage 在 dsl 中
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
});
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;
expect(rootNode?.items.length).toBe(2);
// 选中第一个页面作为当前页面
await editorService.select(NodeId.PAGE_ID);
// 删除当前页面,使用默认行为
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
// 被删除页面在 dsl 中已不存在
expect(editorService.getNodeById(NodeId.PAGE_ID)).toBeNull();
// 自动切到剩余首个页面
expect(editorService.get('page')?.id).toBe(addedId);
});
});
describe('update', () => {
@ -439,6 +539,27 @@ describe('update', () => {
expect(editorService.get('node')?.id).toBe(NodeId.NODE_ID);
expect(editorService.get('node')).toBe(beforeSelected);
});
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 newPageId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
// 选中第一个页面作为当前页
await editorService.select(NodeId.PAGE_ID);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
// 更新非当前页面newPage的配置
await editorService.update({ id: newPageId, type: NodeType.PAGE, name: 'page-renamed' });
// dsl 中该页已更新
expect(editorService.getNodeById(newPageId)?.name).toBe('page-renamed');
// 当前 page 没有被切走
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
});
});
describe('sort', () => {
@ -495,6 +616,24 @@ describe('paste', () => {
// 当前选中节点保持原状
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
test('doNotSwitchPage: true 粘贴页面时不切换当前页面', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
// 复制当前页面
const pageNode = editorService.getNodeById(NodeId.PAGE_ID);
await editorService.copy(pageNode!);
const beforePageId = editorService.get('page')?.id;
expect(beforePageId).toBe(NodeId.PAGE_ID);
const pasted = await editorService.paste({}, undefined, { doNotSwitchPage: true });
// 粘贴成功
expect(pasted).toBeTruthy();
// 当前 page 保持不变
expect(editorService.get('page')?.id).toBe(beforePageId);
});
});
describe('moveLayer', () => {