From 5e7f7ad7f1f8fd520a6eec7e75c9ebf2517dffec Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 5 Nov 2026 11:12:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20insertIndex=20=E6=8C=87=E5=AE=9A=20add=20=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 显式下标不再依赖选中节点锚点,批量添加按父容器累计偏移保持传入顺序;越界或非数字追加到末尾,并补充单测与 API 文档 --- docs/api/editor/editorServiceMethods.md | 8 +- packages/editor/src/sdk/services/Editor.ts | 56 +++++--- packages/editor/src/type.ts | 13 ++ .../editor/tests/unit/services/editor.spec.ts | 126 +++++++++++++++++- 4 files changed, 185 insertions(+), 18 deletions(-) diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index 553a9297..9debd10e 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -35,11 +35,13 @@ DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果( - 数据源:[dataSourceService.revertById(uuids)](./dataSourceServiceMethods.md#revertbyid) - 代码块:[codeBlockService.revertById(uuids)](./codeBlockServiceMethods.md#revertbyid) -::: details 查看 HistoryOpOptions / DslOpOptions / DslOpWithHistoryIdsResult / HistoryOpSource 类型定义 +::: details 查看 HistoryOpOptions / DslOpOptions / AddOpOptions / DslOpWithHistoryIdsResult / HistoryOpSource 类型定义 <<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts} <<< @/../packages/editor/src/type.ts#DslOpOptions{ts} +<<< @/../packages/editor/src/type.ts#AddOpOptions{ts} + <<< @/../packages/editor/src/type.ts#DslOpWithHistoryIdsResult{ts} <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} @@ -386,6 +388,9 @@ editorService.highlight("text_123"); - {`MContainer`} parent 指定的容器节点 + - `{Object}` options 可选配置(类型见 `AddOpOptions`) + - `{number | 'end'}` insertIndex 插入下标;指定后不依赖选中节点,详见 [add](#add) + - **返回:** - {Promise<`MNode`>} 新增的组件 @@ -406,6 +411,7 @@ editorService.highlight("text_123"); - `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点) - `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作) - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{number | 'end'}` insertIndex 插入下标,落在 `[0, 父容器 items.length]`,`'end'` 固定追加到末尾。指定后不按当前选中节点 / 上一次新增节点计算锚点,也不要求有选中节点;越界或非数字统一追加到末尾。批量添加时数字下标是本批节点的起始位置(按父容器分别累计偏移,保持传入顺序),`'end'` 逐个追加。人工添加不传,沿用锚点行为 - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) diff --git a/packages/editor/src/sdk/services/Editor.ts b/packages/editor/src/sdk/services/Editor.ts index 78398673..1155c052 100644 --- a/packages/editor/src/sdk/services/Editor.ts +++ b/packages/editor/src/sdk/services/Editor.ts @@ -35,6 +35,7 @@ import { import type { AddMNode, + AddOpOptions, AsyncHookPlugin, AsyncMethodName, CanDropInFunction, @@ -85,9 +86,11 @@ import { Protocol } from './WebStorage'; 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; +/** 把插入下标收敛到 [0, length]:非整数取整,非法 / 越界(含未记录)一律追加到末尾 */ +const clampIndex = (index: number | undefined, length: number): number => { + const normalized = typeof index === 'number' ? Math.trunc(index) : length; + return Number.isFinite(normalized) && normalized >= 0 && normalized <= length ? normalized : length; +}; /** * 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。 @@ -434,30 +437,44 @@ class Editor extends BaseService { return this.config; } - public async doAdd(node: MNode, parent: MContainer, _options: DslOpOptions = {}): Promise { + public async doAdd(node: MNode, parent: MContainer, options: AddOpOptions = {}): Promise { const root = this.get('root'); if (!root) throw new Error('root为空'); const curNode = this.get('node'); const stage = this.get('stage'); + const explicitIndex = options.insertIndex; - if (!curNode) throw new Error('当前选中节点为空'); + // 未指定 insertIndex 时需要选中节点作为锚点;显式下标不依赖选中状态 + if (!curNode && explicitIndex === undefined) throw new Error('当前选中节点为空'); if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) { throw new Error('app下不能添加组件'); } - // 连续 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; + // 默认:连续 add 时接在上一个新增节点之后,否则接在当前选中节点之后; + // 锚点节点可能已不在 items 中(被删 / DSL 整体替换),回退到当前选中节点,都不在则追加到末尾。 + // 指定 insertIndex 时按显式下标插入,批量写入顺序不再受编辑器选中状态影响。 + let insertIndex: number; + if (explicitIndex === 'end') { + insertIndex = parent.items.length; + } else if (typeof explicitIndex === 'number') { + insertIndex = clampIndex(explicitIndex, parent.items.length); + } else { + // 上面的校验已保证锚点分支存在选中节点 + const selectedId = curNode!.id; + const anchorId = this.lastAdded?.selectedId === selectedId ? this.lastAdded.nodeId : selectedId; + const anchorIndex = isPageOrFragment(node) + ? -1 + : Math.max(getNodeIndex(anchorId, parent), getNodeIndex(selectedId, parent)); + insertIndex = anchorIndex < 0 ? parent.items.length : anchorIndex + 1; + } parent.items.splice(insertIndex, 0, node); - this.lastAdded = { selectedId: curNode.id, nodeId: node.id }; + if (curNode) { + this.lastAdded = { selectedId: curNode.id, nodeId: node.id }; + } const layout = await this.getLayout(this.toRaw(parent), node as MNode); node.style = getInitPositionStyle(node.style, layout); @@ -492,7 +509,7 @@ class Editor extends BaseService { public async add( addNode: AddMNode | MNode[], parent?: MContainer | null, - options: DslOpOptions = {}, + options: AddOpOptions = {}, ): Promise { this.captureSelectionBeforeOp(); @@ -532,8 +549,15 @@ class Editor extends BaseService { // 校验放在真正修改 DSL 之前,strict 模式下校验不通过则整批都不会被添加 const commitInvalid = await this.runGuard(() => this.ctx.validator.guardAdd(targets)); + // 数字下标是整批节点的起始位置:按父容器分别累计偏移,保证批量写入保持传入顺序 + const explicitIndex = options.insertIndex; + const offsets = new Map(); for (const { node, parentNode } of targets) { - newNodes.push(await this.doAdd(node, parentNode, options)); + const offset = offsets.get(parentNode.id) ?? 0; + offsets.set(parentNode.id, offset + 1); + const nodeOptions = + typeof explicitIndex === 'number' ? { ...options, insertIndex: Math.trunc(explicitIndex) + offset } : options; + newNodes.push(await this.doAdd(node, parentNode, nodeOptions)); } // record 模式:在写入历史记录之前落库,使历史快照包含本次的错误状态 @@ -1369,7 +1393,7 @@ class Editor extends BaseService { public async addAndGetHistoryId( addNode: AddMNode | MNode[], parent?: MContainer | null, - options: DslOpOptions = {}, + options: AddOpOptions = {}, ): Promise> { this.lastPushedHistoryId = null; const result = await this.add(addNode, parent, options); diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index d4243ec7..65e91575 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -1491,6 +1491,19 @@ export interface DslOpOptions extends HistoryOpOptions { } // #endregion DslOpOptions +// #region AddOpOptions +/** + * {@link EditorService.add} / {@link EditorService.doAdd} 的额外配置 + * - insertIndex: 插入下标,落在 [0, 父容器 items.length];'end' 表示固定追加到末尾。 + * 指定后不再按当前选中节点与上一次新增节点计算锚点,也不要求有选中节点;越界 / 非数字一律追加到末尾。 + * 批量 add 时数字下标是本批节点的起始位置,按父容器分别累计偏移,保持传入顺序;'end' 逐个追加。 + * 页面 / 页面片作用于 root.items,同样不再固定追加到末尾。人工添加不传,沿用锚点行为。 + */ +export interface AddOpOptions extends DslOpOptions { + insertIndex?: number | 'end'; +} +// #endregion AddOpOptions + // #region UpdateOptions /** * {@link EditorService.update} / {@link EditorService.doUpdate} 的额外数据 diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index a39a7c69..d475e1cc 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -19,7 +19,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { cloneDeep } from 'lodash-es'; -import type { MApp, MContainer, MNode } from '@tmagic/core'; +import type { Id, MApp, MContainer, MNode } from '@tmagic/core'; import { NodeType } from '@tmagic/core'; import { getNodePath } from '@tmagic/utils'; @@ -526,6 +526,130 @@ describe('add', () => { // 当前 page 保持不变(没有自动切到新加的页面) expect(editorService.get('page')?.id).toBe(beforePageId); }); + + test('insertIndex 指定下标时不受当前选中节点影响', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.NODE_ID); + + const first = await editorService.add({ id: 'explicit-a', type: 'text', style: {} }, null, { + doNotSelect: true, + insertIndex: 0, + }); + const firstId = Array.isArray(first) ? first[0].id : first.id; + + // 换一个选中节点,仍按显式下标插入到末尾 + await editorService.select(NodeId.NODE_ID2); + const second = await editorService.add({ id: 'explicit-b', type: 'text', style: {} }, null, { + doNotSelect: true, + insertIndex: 'end', + }); + const secondId = Array.isArray(second) ? second[0].id : second.id; + + const parent = editorService.getParentById(firstId); + expect(parent?.items.map((item) => item.id)).toEqual([firstId, NodeId.NODE_ID, NodeId.NODE_ID2, secondId]); + }); + + test('insertIndex 越界或非有限时统一追加到末尾', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.NODE_ID); + + const ids: Id[] = []; + for (const [i, insertIndex] of [99, -5, Number.NaN, Number.POSITIVE_INFINITY].entries()) { + const added = await editorService.add({ id: `clamp-${i}`, type: 'text', style: {} }, null, { + doNotSelect: true, + insertIndex, + }); + ids.push(Array.isArray(added) ? added[0].id : added.id); + } + + const parent = editorService.getParentById(ids[0]); + expect(parent?.items.map((item) => item.id)).toEqual([NodeId.NODE_ID, NodeId.NODE_ID2, ...ids]); + }); + + test('批量 add 的数字下标为起始位置、end 逐个追加,均保持传入顺序', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.NODE_ID); + + const firstBatch = ['batch-a', 'batch-b', 'batch-c'].map((id) => ({ id, type: 'text', style: {} })); + await editorService.add(firstBatch, null, { doNotSelect: true, insertIndex: 0 }); + + const secondBatch = ['batch-d', 'batch-e'].map((id) => ({ id, type: 'text', style: {} })); + await editorService.add(secondBatch, null, { doNotSelect: true, insertIndex: 'end' }); + + const parent = editorService.getParentById('batch-a'); + expect(parent?.items.map((item) => item.id)).toEqual([ + 'batch-a', + 'batch-b', + 'batch-c', + NodeId.NODE_ID, + NodeId.NODE_ID2, + 'batch-d', + 'batch-e', + ]); + }); + + test('显式下标不要求选中节点,未指定时仍要求选中', async () => { + editorService.set('root', cloneDeep(root)); + editorService.set('node', null); + editorService.set('nodes', []); + const parent = editorService.getNodeById(NodeId.PAGE_ID) as MContainer; + + const added = await editorService.add({ id: 'no-selection', type: 'text', style: {} }, parent, { + doNotSelect: true, + insertIndex: 0, + }); + const addedId = Array.isArray(added) ? added[0].id : added.id; + expect(parent.items.map((item) => item.id)).toEqual([addedId, NodeId.NODE_ID, NodeId.NODE_ID2]); + + await expect( + editorService.add({ id: 'anchor', type: 'text', style: {} }, parent, { doNotSelect: true }), + ).rejects.toThrow('当前选中节点为空'); + }); + + test('小数下标取整后同步传给画布', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.NODE_ID); + const stageAdd = vi.fn(() => Promise.resolve()); + editorService.set('stage', { add: stageAdd } as any); + + let addedId: Id = ''; + try { + const added = await editorService.add({ id: 'stage-index', type: 'text', style: { left: 0, top: 0 } }, null, { + doNotSelect: true, + insertIndex: 1.8, + }); + addedId = Array.isArray(added) ? added[0].id : added.id; + } finally { + editorService.set('stage', null); + } + + expect(stageAdd).toHaveBeenCalledWith(expect.objectContaining({ index: 1 })); + const parent = editorService.getParentById(addedId); + expect(parent?.items.map((item) => item.id)).toEqual([NodeId.NODE_ID, addedId, NodeId.NODE_ID2]); + }); + + test('显式下标的撤销 / 重做仍还原插入位置', async () => { + editorService.set('root', cloneDeep(root)); + historyService.reset(); + await editorService.select(NodeId.NODE_ID); + + const added = await editorService.add({ id: 'history-index', type: 'text', style: {} }, null, { + doNotSelect: true, + insertIndex: 0, + }); + const addedId = Array.isArray(added) ? added[0].id : added.id; + expect(editorService.getParentById(addedId)?.items[0].id).toBe(addedId); + + await editorService.undo(); + expect(editorService.getNodeById(addedId)).toBeNull(); + + await editorService.redo(); + expect(editorService.getParentById(addedId)?.items.map((item) => item.id)).toEqual([ + addedId, + NodeId.NODE_ID, + NodeId.NODE_ID2, + ]); + }); }); describe('remove', () => {