fix(editor): 修复连续 add 与批量插入时节点顺序错乱

通过 lastAdded 记忆插入锚点、批量 add 改为串行,并向 runtime 传递 index 以保证编辑器与画布 DSL 顺序一致。
This commit is contained in:
roymondchen 2026-08-07 11:56:41 +08:00
parent f435dde1d3
commit a7198e2731
6 changed files with 191 additions and 32 deletions

View File

@ -83,6 +83,10 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
/** 历史插回时把记录的下标收敛到 [0, length],越界(含未记录)一律追加到末尾 */
const clampIndex = (index: number | undefined, length: number): number =>
typeof index === 'number' && index >= 0 && index <= length ? index : length;
/**
* update {@link StepDiffItem} {@link StepValue.diff} 使
* `changeRecords` form propPath/value / propPath
@ -126,6 +130,11 @@ class Editor extends BaseService {
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
/**
* doAdd nodeId selectedId
* add / doNotSelect /
*/
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
constructor() {
super(
@ -418,13 +427,16 @@ class Editor extends BaseService {
throw new Error('app下不能添加组件');
}
if (parent.id !== curNode.id && !isPageOrFragment(node)) {
const index = parent.items.indexOf(curNode);
parent.items?.splice(index + 1, 0, node);
} else {
// 新增节点添加到配置中
parent.items?.push(node);
}
// 连续 add 时接在上一个新增节点之后,否则接在当前选中节点之后;
// 锚点节点可能已不在 items 中(被删 / DSL 整体替换),回退到当前选中节点,都不在则追加到末尾
const anchorId = this.lastAdded?.selectedId === curNode.id ? this.lastAdded.nodeId : curNode.id;
const anchorIndex = isPageOrFragment(node)
? -1
: Math.max(getNodeIndex(anchorId, parent), getNodeIndex(curNode.id, parent));
const insertIndex = anchorIndex < 0 ? parent.items.length : anchorIndex + 1;
parent.items.splice(insertIndex, 0, node);
this.lastAdded = { selectedId: curNode.id, nodeId: node.id };
const layout = await this.getLayout(toRaw(parent), node as MNode);
node.style = getInitPositionStyle(node.style, layout);
@ -434,6 +446,7 @@ class Editor extends BaseService {
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
index: insertIndex,
});
const newStyle = fixNodePosition(node, parent, stage);
@ -485,17 +498,15 @@ class Editor extends BaseService {
addNodes.push(...addNode);
}
const newNodes = await Promise.all(
addNodes.map((node) => {
const root = this.get('root');
if (isPageOrFragment(node) && root) {
return this.doAdd(node, root);
}
const parentNode = parent ?? getAddParent(node);
if (!parentNode) throw new Error('未找到父元素');
return this.doAdd(node, parentNode);
}),
);
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
const newNodes: MNode[] = [];
for (const node of addNodes) {
const root = this.get('root');
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(node));
if (!parentNode) throw new Error('未找到父元素');
newNodes.push(await this.doAdd(node, parentNode));
}
if (newNodes.length > 1) {
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
@ -1987,17 +1998,15 @@ class Editor extends BaseService {
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(newSchema);
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, addedNode);
} else {
parent.items.push(addedNode);
}
const insertIndex = clampIndex(index, parent.items.length);
parent.items.splice(insertIndex, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(newSchema),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
index: insertIndex,
});
}
}
@ -2016,13 +2025,15 @@ class Editor extends BaseService {
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(oldSchema);
parent.items.splice(index ?? parent.items.length, 0, addedNode);
const insertIndex = clampIndex(index, parent.items.length);
parent.items.splice(insertIndex, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(oldSchema),
parent: cloneDeep(parent),
parentId,
root: cloneDeep(root),
index: insertIndex,
});
}
}

View File

@ -430,6 +430,87 @@ describe('add', () => {
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
test('doNotSelect: true 连续多次 add 保持插入顺序', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const first = await editorService.add({ type: 'text' }, null, { doNotSelect: true });
const second = await editorService.add({ type: 'text' }, null, { doNotSelect: true });
const firstId = Array.isArray(first) ? first[0].id : first.id;
const secondId = Array.isArray(second) ? second[0].id : second.id;
const parent = editorService.getParentById(firstId);
const ids = parent?.items.map((item) => item.id);
// 应紧接在原选中节点之后,且先后顺序不被后一次 splice 颠倒
expect(ids).toEqual([NodeId.NODE_ID, firstId, secondId, NodeId.NODE_ID2]);
expect(editorService.get('node')?.id).toBe(NodeId.NODE_ID);
});
test('批量 add 多个节点时按传入顺序插入', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const added = await editorService.add(
[
{ id: 'batch-a', type: 'text', style: {} },
{ id: 'batch-b', type: 'text', style: {} },
{ id: 'batch-c', type: 'text', style: {} },
],
null,
{ doNotSelect: true },
);
expect(Array.isArray(added)).toBe(true);
const parent = editorService.getParentById('batch-a');
expect(parent?.items.map((item) => item.id)).toEqual([
NodeId.NODE_ID,
'batch-a',
'batch-b',
'batch-c',
NodeId.NODE_ID2,
]);
});
test('插件注册异步 beforeDoAdd 时批量 add 仍按顺序插入', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
// beforeDoAdd 是公开插件钩子,异步实现会让 doAdd 在插入节点前先让出线程;
// 这里刻意让耗时递减,插入顺序不能依赖钩子的返回快慢
const delays = [30, 20, 10];
let callIndex = 0;
const beforeDoAdd = async (...args: any[]) => {
const delay = delays[callIndex] ?? 0;
callIndex += 1;
await new Promise((resolve) => setTimeout(resolve, delay));
return args;
};
editorService.usePlugin({ beforeDoAdd });
try {
await editorService.add(
[
{ id: 'hook-a', type: 'text', style: {} },
{ id: 'hook-b', type: 'text', style: {} },
{ id: 'hook-c', type: 'text', style: {} },
] as MNode[],
null,
{ doNotSelect: true },
);
const parent = editorService.getParentById('hook-a');
expect(parent?.items.map((item) => item.id)).toEqual([
NodeId.NODE_ID,
'hook-a',
'hook-b',
'hook-c',
NodeId.NODE_ID2,
]);
} finally {
editorService.removePlugin({ beforeDoAdd });
}
});
test('doNotSwitchPage: true 新增页面时保持当前页面不切换', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);

View File

@ -235,6 +235,8 @@ export interface UpdateData {
parent?: MContainer;
parentId?: Id;
root: MApp;
/** 插入到父节点 items 中的目标下标runtime 优先按此同步,避免依赖选中态推算顺序 */
index?: number;
}
export interface RemoveData {

View File

@ -73,7 +73,7 @@ export const useFormConfig = (props: AppProps) => {
return nextTick().then(() => getElById()(document, `${id}`) as HTMLElement);
},
add({ config, parentId, root: appConfig }: UpdateData) {
add({ config, parentId, root: appConfig, index }: UpdateData) {
if (!root.value) {
if (appConfig) {
root.value = appConfig;
@ -93,9 +93,12 @@ export const useFormConfig = (props: AppProps) => {
parentNode && app?.page?.initNode(config, parentNode);
}
if (parent.id !== selectedId.value) {
const index = parent.items?.findIndex((child: MNode) => child.id === selectedId.value);
parent.items?.splice(index + 1, 0, config);
// 编辑器传了 index 就按它对齐;老版本编辑器不带 index保持原有的「接在选中节点之后」逻辑
if (typeof index === 'number' && index >= 0 && index <= (parent.items?.length ?? 0)) {
parent.items?.splice(index, 0, config);
} else if (parent.id !== selectedId.value) {
const selectedIndex = parent.items?.findIndex((child: MNode) => child.id === selectedId.value);
parent.items?.splice(selectedIndex + 1, 0, config);
} else {
// 新增节点添加到配置中
parent.items?.push(config);

View File

@ -88,7 +88,7 @@ export const useEditorDsl = (app = inject<TMagicApp>('app'), runtimeApi: Runtime
return nextTick().then(() => getElById()(document, `${id}`));
},
add: ({ config, parentId, root: appConfig }: UpdateData) => {
add: ({ config, parentId, root: appConfig, index }: UpdateData) => {
if (!root.value) {
if (appConfig) {
updateRoot(appConfig);
@ -107,9 +107,13 @@ export const useEditorDsl = (app = inject<TMagicApp>('app'), runtimeApi: Runtime
parentNode && app?.page?.initNode(config, parentNode);
}
if (parent.id !== selectedId.value) {
const index = parent.items?.findIndex((child: MNode) => child.id === selectedId.value);
parent.items?.splice(index + 1, 0, config);
// 编辑器传了 index 就按它对齐,避免连续 add / 撤销重做时与选中态推算的结果不一致;
// 老版本编辑器不带 index保持原有的「接在选中节点之后」逻辑
if (typeof index === 'number' && index >= 0 && index <= (parent.items?.length ?? 0)) {
parent.items?.splice(index, 0, config);
} else if (parent.id !== selectedId.value) {
const selectedIndex = parent.items?.findIndex((child: MNode) => child.id === selectedId.value);
parent.items?.splice(selectedIndex + 1, 0, config);
} else {
// 新增节点添加到配置中
parent.items?.push(config);

View File

@ -38,6 +38,64 @@ const setup = (curPageId?: string) => {
return { app, runtime, dsl };
};
describe('useEditorDsl add', () => {
beforeEach(() => {
(window as any).magic = undefined;
});
test('传入 index 时按指定下标插入,不依赖 selectedId', () => {
const { runtime, dsl } = setup();
runtime.select?.('btn');
runtime.add?.({
config: { id: 'n1', type: 'text' } as any,
parentId: 'p1',
root: dsl,
index: 0,
});
expect(dsl.items[0].items?.map((item) => item.id)).toEqual(['n1', 'btn']);
});
test('不传 index 时保持原有逻辑:接在选中节点之后', () => {
const { runtime, dsl } = setup();
runtime.select?.('btn');
runtime.add?.({ config: { id: 'n1', type: 'text' } as any, parentId: 'p1', root: dsl });
expect(dsl.items[0].items?.map((item) => item.id)).toEqual(['btn', 'n1']);
});
test('不传 index 且选中的就是父容器时追加到末尾', () => {
const { runtime, dsl } = setup();
runtime.select?.('p1');
runtime.add?.({ config: { id: 'n1', type: 'text' } as any, parentId: 'p1', root: dsl });
expect(dsl.items[0].items?.map((item) => item.id)).toEqual(['btn', 'n1']);
});
test('连续按递增 index 插入时保持顺序', () => {
const { runtime, dsl } = setup();
runtime.select?.('btn');
runtime.add?.({
config: { id: 'n1', type: 'text' } as any,
parentId: 'p1',
root: dsl,
index: 1,
});
runtime.add?.({
config: { id: 'n2', type: 'text' } as any,
parentId: 'p1',
root: dsl,
index: 2,
});
expect(dsl.items[0].items?.map((item) => item.id)).toEqual(['btn', 'n1', 'n2']);
});
});
describe('useEditorDsl remove', () => {
beforeEach(() => {
(window as any).magic = undefined;