mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-11-05 17:40:54 +00:00
feat(editor): 支持通过 insertIndex 指定 add 插入位置
显式下标不再依赖选中节点锚点,批量添加按父容器累计偏移保持传入顺序;越界或非数字追加到末尾,并补充单测与 API 文档
This commit is contained in:
parent
c50c245fbb
commit
5e7f7ad7f1
@ -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)
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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} 的额外数据
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user