feat(editor): 支持通过 insertIndex 指定 add 插入位置

显式下标不再依赖选中节点锚点,批量添加按父容器累计偏移保持传入顺序;越界或非数字追加到末尾,并补充单测与 API 文档
This commit is contained in:
roymondchen 2026-11-05 11:12:57 +08:00
parent c50c245fbb
commit 5e7f7ad7f1
4 changed files with 185 additions and 18 deletions

View File

@ -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)

View File

@ -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<MNode> {
public async doAdd(node: MNode, parent: MContainer, options: AddOpOptions = {}): Promise<MNode> {
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<MNode | MNode[]> {
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<Id, number>();
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<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.add(addNode, parent, options);

View File

@ -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} 的额外数据

View File

@ -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', () => {