diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 24045b5b..0b0b262f 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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, }); } } diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index ac7ed82c..fae7c6b7 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -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); diff --git a/packages/stage/src/types.ts b/packages/stage/src/types.ts index e2ef71ee..4e6f5d03 100644 --- a/packages/stage/src/types.ts +++ b/packages/stage/src/types.ts @@ -235,6 +235,8 @@ export interface UpdateData { parent?: MContainer; parentId?: Id; root: MApp; + /** 插入到父节点 items 中的目标下标;runtime 优先按此同步,避免依赖选中态推算顺序 */ + index?: number; } export interface RemoveData { diff --git a/runtime/tmagic-form/src/useFormConfig.ts b/runtime/tmagic-form/src/useFormConfig.ts index 51aea637..e0407b64 100644 --- a/runtime/tmagic-form/src/useFormConfig.ts +++ b/runtime/tmagic-form/src/useFormConfig.ts @@ -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); diff --git a/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts b/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts index 7d590a61..09bf22a2 100644 --- a/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts +++ b/runtime/vue-runtime-help/src/hooks/use-editor-dsl.ts @@ -88,7 +88,7 @@ export const useEditorDsl = (app = inject('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('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); diff --git a/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts b/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts index d5b90ca5..cf907d5d 100644 --- a/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts +++ b/runtime/vue-runtime-help/tests/use-editor-dsl.test.ts @@ -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;