mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-12 15:09:43 +00:00
fix(editor): 修复连续 add 与批量插入时节点顺序错乱
通过 lastAdded 记忆插入锚点、批量 add 改为串行,并向 runtime 传递 index 以保证编辑器与画布 DSL 顺序一致。
This commit is contained in:
parent
f435dde1d3
commit
a7198e2731
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -235,6 +235,8 @@ export interface UpdateData {
|
||||
parent?: MContainer;
|
||||
parentId?: Id;
|
||||
root: MApp;
|
||||
/** 插入到父节点 items 中的目标下标;runtime 优先按此同步,避免依赖选中态推算顺序 */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface RemoveData {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user