1
0
mirror of synced 2026-05-20 09:18:10 +08:00

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

- add/remove/sort/alignCenter/moveToContainer/paste 新增 doNotSelect 选项,控制操作后是否自动触发选中变化
- doUpdate/doRemove 改为始终同步当前选中列表中的节点引用,避免 state 持有已被替换/已删除的过期节点
- 顺手修复 doUpdate 在 splice(-1) 时误改最后一个选中项的 bug
- 移除 update/doUpdate 的 selectedAfterUpdate 参数(语义已内化),move 不再暴露无意义的 doNotSelect
- 新增 safeOptions / safeParent 辅助函数,兜底插件机制将 dispatch 注入到形参位置的场景

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen
2026-05-19 17:20:04 +08:00
parent 1e69bc221d
commit 05e512b1fe
3 changed files with 287 additions and 42 deletions

View File

@@ -332,6 +332,9 @@ editorService.highlight("text_123");
- {`MContainer`} parent 指定的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
@@ -352,6 +355,8 @@ editorService.highlight("text_123");
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是
- **参数:**
- {`MNode`} node 要删除的节点
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false
- **返回:**
- `{Promise<void>}`
@@ -366,6 +371,8 @@ editorService.highlight("text_123");
- **参数:**
- {`MNode` | `MNode`[])} node 要删除的节点或节点集合
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
- **返回:**
- `{Promise<void>}`
@@ -390,7 +397,6 @@ editorService.highlight("text_123");
- {`MNode`} config 新的节点
- `{Object}` data 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` selectedAfterUpdate 更新后是否将新节点同步到当前选中节点列表
- **返回:**
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
@@ -405,6 +411,8 @@ editorService.highlight("text_123");
:::tip
节点中应该要有id不然不知道要更新哪个节点
当被更新节点正好在当前选中列表中时state 会自动同步到新的节点引用,无需调用方处理
:::
## update
@@ -415,7 +423,6 @@ editorService.highlight("text_123");
- {`MNode` | `MNode`[]} config 新的节点或节点集合
- `{Object}` data 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` selectedAfterUpdate 更新后是否同步到当前选中节点列表
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合
@@ -439,6 +446,8 @@ editorService.highlight("text_123");
- **参数:**
- `{ string | number }` id1
- `{ string | number }` id2
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false
- **返回:**
- `{Promise<void>}`
@@ -502,6 +511,10 @@ editorService.highlight("text_123");
<<< @/../packages/editor/src/type.ts#PastePosition{ts}
:::
- `{TargetOptions}` collectorOptions 可选的依赖收集器配置
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false
- **返回:**
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
@@ -535,6 +548,8 @@ editorService.highlight("text_123");
- **参数:**
- {`MNode` | `MNode`[]} config 需要居中的组件或者组件集合
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false
- **返回:**
- {Promise<`MNode` | `MNode`[]>}
@@ -572,6 +587,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **参数:**
- {`MNode`} config 需要移动的节点
- `{string | number}` targetId 容器ID
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false
- **返回:**
- Promise<`MNode` | undefined>

View File

@@ -65,6 +65,25 @@ import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
import { beforePaste, getAddParent } from '@editor/utils/operator';
/**
* 经过 BaseService 的插件 / 中间件包装后,源方法的最后一个形参可能被注入为 dispatch 函数
* 当 options 形参位置被注入为函数(或为 null将其归一为空对象避免后续逻辑误读
*/
const safeOptions = <T extends object>(options: unknown): T => {
const empty = {};
if (!options || typeof options === 'function') return empty as T;
return options as T;
};
/**
* 经过 BaseService 的插件 / 中间件包装后,源方法的形参可能被注入为 dispatch 函数
* 当 parent 形参位置被注入为函数(或为空值)时,归一为 null由调用方继续走默认 parent 逻辑
*/
const safeParent = (parent: unknown): MContainer | null => {
if (!parent || typeof parent === 'function') return null;
return parent as MContainer;
};
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,
@@ -349,9 +368,18 @@ class Editor extends BaseService {
* 向指点容器添加组件节点
* @param addConfig 将要添加的组件节点配置
* @param parent 要添加到的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
* @param options 可选配置
* @param options.doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点
* @returns 添加后的节点
*/
public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> {
public async add(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
options?: { doNotSelect?: boolean },
): Promise<MNode | MNode[]> {
const safeParentNode = safeParent(parent);
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const stage = this.get('stage');
@@ -374,25 +402,29 @@ class Editor extends BaseService {
if ((isPage(node) || isPageFragment(node)) && root) {
return this.doAdd(node, root);
}
const parentNode = parent && typeof parent !== 'function' ? parent : getAddParent(node);
const parentNode = safeParentNode ?? getAddParent(node);
if (!parentNode) throw new Error('未找到父元素');
return this.doAdd(node, parentNode);
}),
);
if (newNodes.length > 1) {
const newNodeIds = newNodes.map((node) => node.id);
// 触发选中样式
stage?.multiSelect(newNodeIds);
await this.multiSelect(newNodeIds);
if (!doNotSelect) {
const newNodeIds = newNodes.map((node) => node.id);
// 触发选中样式
stage?.multiSelect(newNodeIds);
await this.multiSelect(newNodeIds);
}
} else {
await this.select(newNodes[0]);
if (!doNotSelect) {
await this.select(newNodes[0]);
}
if (isPage(newNodes[0])) {
this.state.pageLength += 1;
} else if (isPageFragment(newNodes[0])) {
this.state.pageFragmentLength += 1;
} else {
} else if (!doNotSelect) {
// 新增页面这个时候页面还有渲染出来此时select会出错在runtime-ready的时候回去select
stage?.select(newNodes[0].id);
}
@@ -421,7 +453,9 @@ class Editor extends BaseService {
return Array.isArray(addNode) ? newNodes : newNodes[0];
}
public async doRemove(node: MNode): Promise<void> {
public async doRemove(node: MNode, options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
const root = this.get('root');
if (!root) throw new Error('root不能为空');
@@ -437,6 +471,24 @@ 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);
}
}
}
const selectDefault = async (pages: MNode[]) => {
if (pages[0]) {
await this.select(pages[0]);
@@ -453,14 +505,20 @@ class Editor extends BaseService {
if (isPage(node)) {
this.state.pageLength -= 1;
await selectDefault(rootItems);
if (!doNotSelect) {
await selectDefault(rootItems);
}
} else if (isPageFragment(node)) {
this.state.pageFragmentLength -= 1;
await selectDefault(rootItems);
if (!doNotSelect) {
await selectDefault(rootItems);
}
} else {
await this.select(parent);
stage?.select(parent.id);
if (!doNotSelect) {
await this.select(parent);
stage?.select(parent.id);
}
this.addModifiedNodeId(parent.id);
}
@@ -473,9 +531,13 @@ class Editor extends BaseService {
/**
* 删除组件
* @param {Object} node
* @param {Object} node 要删除的节点或节点集合
* @param options 可选配置
* @param options.doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
*/
public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> {
public async remove(nodeOrNodeList: MNode | MNode[], options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
@@ -499,7 +561,7 @@ class Editor extends BaseService {
}
}
await Promise.all(nodes.map((node) => this.doRemove(node)));
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect })));
if (removedItems.length > 0 && pageForOp) {
this.pushOpHistory('remove', { removedItems }, pageForOp);
@@ -510,10 +572,7 @@ class Editor extends BaseService {
public async doUpdate(
config: MNode,
{
changeRecords = [],
selectedAfterUpdate = true,
}: { changeRecords?: ChangeRecord[]; selectedAfterUpdate?: boolean } = {},
{ changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
const root = this.get('root');
if (!root) throw new Error('root为空');
@@ -557,12 +616,12 @@ class Editor extends BaseService {
parentNodeItems[index] = newConfig;
// 将update后的配置更新到nodes中
if (selectedAfterUpdate) {
const nodes = this.get('nodes');
const targetIndex = nodes.findIndex((nodeItem: MNode) => `${nodeItem.id}` === `${newConfig.id}`);
nodes.splice(targetIndex, 1, newConfig);
this.set('nodes', [...nodes]);
// 当被更新节点正好在当前选中列表中时,必须同步引用,否则 state 会持有已被替换的旧节点
const selectedNodes = this.get('nodes');
const targetIndex = selectedNodes.findIndex((nodeItem: MNode) => `${nodeItem.id}` === `${newConfig.id}`);
if (targetIndex !== -1) {
selectedNodes.splice(targetIndex, 1, newConfig);
this.set('nodes', [...selectedNodes]);
}
if (isPage(newConfig) || isPageFragment(newConfig)) {
@@ -586,7 +645,7 @@ class Editor extends BaseService {
*/
public async update(
config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[]; selectedAfterUpdate?: boolean } = {},
data: { changeRecords?: ChangeRecord[] } = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
@@ -620,9 +679,13 @@ class Editor extends BaseService {
* 将id为id1的组件移动到id为id2的组件位置上例如[1,2,3,4] -> sort(1,3) -> [2,1,3,4]
* @param id1 组件ID
* @param id2 组件ID
* @param options 可选配置
* @param options.doNotSelect 排序后是否不更新当前选中节点(默认 false
* @returns void
*/
public async sort(id1: Id, id2: Id): Promise<void> {
public async sort(id1: Id, id2: Id, options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const root = this.get('root');
@@ -642,7 +705,9 @@ class Editor extends BaseService {
parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
await this.update(parent);
await this.select(node);
if (!doNotSelect) {
await this.select(node);
}
this.get('stage')?.update({
config: cloneDeep(node),
@@ -682,9 +747,18 @@ class Editor extends BaseService {
/**
* 从localStorage中获取节点然后添加到当前容器中
* @param position 粘贴的坐标
* @param collectorOptions 可选的依赖收集器配置
* @param options 可选配置
* @param options.doNotSelect 粘贴后是否不更新当前选中节点(默认 false
* @returns 添加后的组件节点配置
*/
public async paste(position: PastePosition = {}, collectorOptions?: TargetOptions): Promise<MNode | MNode[] | void> {
public async paste(
position: PastePosition = {},
collectorOptions?: TargetOptions,
options?: { doNotSelect?: boolean },
): Promise<MNode | MNode[] | void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return;
@@ -704,7 +778,7 @@ class Editor extends BaseService {
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
}
return this.add(pasteConfigs, parent);
return this.add(pasteConfigs, parent, { doNotSelect });
}
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
@@ -732,9 +806,13 @@ class Editor extends BaseService {
/**
* 将指点节点设置居中
* @param config 组件节点配置
* @param options 可选配置
* @param options.doNotSelect 居中后是否不更新当前选中节点(默认 false
* @returns 当前组件节点配置
*/
public async alignCenter(config: MNode | MNode[]): Promise<MNode | MNode[]> {
public async alignCenter(config: MNode | MNode[], options?: { doNotSelect?: boolean }): Promise<MNode | MNode[]> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
const nodes = Array.isArray(config) ? config : [config];
const stage = this.get('stage');
@@ -742,10 +820,12 @@ class Editor extends BaseService {
const newNode = await this.update(newNodes);
if (newNodes.length > 1) {
await stage?.multiSelect(newNodes.map((node) => node.id));
} else {
await stage?.select(newNodes[0].id);
if (!doNotSelect) {
if (newNodes.length > 1) {
await stage?.multiSelect(newNodes.map((node) => node.id));
} else {
await stage?.select(newNodes[0].id);
}
}
return newNode;
@@ -808,8 +888,16 @@ class Editor extends BaseService {
* 移动到指定容器中
* @param config 需要移动的节点
* @param targetId 容器ID
* @param options 可选配置
* @param options.doNotSelect 移动后是否不更新当前选中节点(默认 false
*/
public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> {
public async moveToContainer(
config: MNode,
targetId: Id,
options?: { doNotSelect?: boolean },
): Promise<MNode | undefined> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const root = this.get('root');
@@ -837,7 +925,9 @@ class Editor extends BaseService {
target.items.push(newConfig);
await stage.select(targetId);
if (!doNotSelect) {
await stage.select(targetId);
}
const targetParent = this.getParentById(target.id);
await stage.update({
@@ -846,8 +936,10 @@ class Editor extends BaseService {
root: cloneDeep(root),
});
await this.select(newConfig);
stage.select(newConfig.id);
if (!doNotSelect) {
await this.select(newConfig);
stage.select(newConfig.id);
}
this.addModifiedNodeId(target.id);
this.addModifiedNodeId(parent.id);

View File

@@ -286,6 +286,23 @@ describe('add', () => {
),
).rejects.toThrowError('app下不能添加组件');
});
test('doNotSelect: true 不更新选中节点', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const beforeNodeId = editorService.get('node')?.id;
expect(beforeNodeId).toBe(NodeId.NODE_ID);
const newNode = await editorService.add({ type: 'text' }, null, { doNotSelect: true });
// 节点已被添加到 dsl
const addedId = Array.isArray(newNode) ? newNode[0].id : newNode.id;
const parentInfo = editorService.getParentById(addedId);
expect(parentInfo?.items).toHaveLength(3);
// 但当前选中节点保持原状(未自动选中新增节点)
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
});
describe('remove', () => {
@@ -316,6 +333,36 @@ describe('remove', () => {
test('undefine', async () => {
expect(() => editorService.remove({ id: NodeId.ERROR_NODE_ID, type: 'text' })).rejects.toThrow();
});
test('doNotSelect: true 不更新选中节点', async () => {
editorService.set('root', cloneDeep(root));
// 选中 NODE_ID删除另外一个 NODE_ID2
await editorService.select(NodeId.NODE_ID);
const beforeNodeId = editorService.get('node')?.id;
expect(beforeNodeId).toBe(NodeId.NODE_ID);
await editorService.remove({ id: NodeId.NODE_ID2, type: 'text' }, { doNotSelect: true });
// 节点已被删除
expect(editorService.getNodeById(NodeId.NODE_ID2)).toBeNull();
// 当前选中节点保持原状(未自动选中父节点)
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
test('被删除节点正好是当前选中节点时state 强制移除引用', async () => {
editorService.set('root', cloneDeep(root));
// 选中 NODE_ID 后再删除 NODE_ID 自身
await editorService.select(NodeId.NODE_ID);
expect(editorService.get('node')?.id).toBe(NodeId.NODE_ID);
// 即使 doNotSelect: true被删除节点正好是当前选中节点时state 也必须移除引用
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' }, { doNotSelect: true });
// 节点已删除
expect(editorService.getNodeById(NodeId.NODE_ID)).toBeNull();
// state.nodes 中不再包含被删除的节点
expect(editorService.get('nodes').some((n) => n.id === NodeId.NODE_ID)).toBe(false);
});
});
describe('update', () => {
@@ -365,6 +412,33 @@ describe('update', () => {
const node2 = editorService.getNodeById(NodeId.NODE_ID);
expect(node2?.style?.position).toBe('absolute');
});
test('被更新节点正好是当前选中节点时state.node 始终与 dsl 同步', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'updated-text' });
// dsl 已更新
expect(editorService.getNodeById(NodeId.NODE_ID)?.text).toBe('updated-text');
// state.node 引用同步到新节点,不会持有过期数据
expect(editorService.get('node')?.text).toBe('updated-text');
});
test('更新非选中节点时,不影响当前选中列表', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const beforeSelected = editorService.get('node');
// 更新另一个非选中节点
await editorService.update({ id: NodeId.NODE_ID2, type: 'text', text: 'other-text' });
// dsl 已更新
expect(editorService.getNodeById(NodeId.NODE_ID2)?.text).toBe('other-text');
// 原选中节点引用不被错误替换(修复 splice(-1) 误改最后一个选中项的旧 bug
expect(editorService.get('node')?.id).toBe(NodeId.NODE_ID);
expect(editorService.get('node')).toBe(beforeSelected);
});
});
describe('sort', () => {
@@ -378,6 +452,19 @@ describe('sort', () => {
parent = editorService.get('parent');
expect(parent?.items[0].id).toBe(NodeId.NODE_ID2);
});
test('doNotSelect: true 完成排序且不触发额外 select', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID2);
const parentBefore = editorService.get('parent');
expect(parentBefore?.items[0].id).toBe(NodeId.NODE_ID);
await editorService.sort(NodeId.NODE_ID2, NodeId.NODE_ID, { doNotSelect: true });
// dsl 顺序已更新
const parentAfter = editorService.getParentById(NodeId.NODE_ID2);
expect(parentAfter?.items[0].id).toBe(NodeId.NODE_ID2);
});
});
describe('copy', () => {
@@ -390,6 +477,26 @@ describe('copy', () => {
});
});
describe('paste', () => {
test('doNotSelect: true 不更新选中节点', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const sourceNode = editorService.getNodeById(NodeId.NODE_ID2);
await editorService.copy(sourceNode!);
const beforeNodeId = editorService.get('node')?.id;
expect(beforeNodeId).toBe(NodeId.NODE_ID);
const pasted = await editorService.paste({}, undefined, { doNotSelect: true });
// 粘贴成功
expect(pasted).toBeTruthy();
// 当前选中节点保持原状
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
});
describe('moveLayer', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
@@ -402,6 +509,35 @@ describe('moveLayer', () => {
});
});
describe('插件参数兜底', () => {
test('add 的 parent 形参传入函数时不抛错,仍走默认父节点逻辑', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
// 模拟 BaseService 中间件机制在 parent 位置注入 dispatch 函数
const dispatchFn = () => {};
const newNode = await editorService.add({ type: 'text' }, dispatchFn as any);
// 默认行为:被加到了当前选中节点的父节点 (PAGE)
const addedId = Array.isArray(newNode) ? newNode[0].id : newNode.id;
const parentInfo = editorService.getParentById(addedId);
expect(parentInfo?.id).toBe(NodeId.PAGE_ID);
});
test('add 的 options 形参传入函数时不抛错doNotSelect 回落为默认值', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
// 模拟 BaseService 中间件机制在 options 位置注入 dispatch 函数
const dispatchFn = () => {};
const newNode = await editorService.add({ type: 'text' }, null, dispatchFn as any);
// 默认行为:当前选中节点变成了新增节点
const addedId = Array.isArray(newNode) ? newNode[0].id : newNode.id;
expect(editorService.get('node')?.id).toBe(addedId);
});
});
describe('undo redo', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));