refactor(editor): 拆分 editor service,提取工具函数减少文件行数

将 services/editor.ts 从 1335 行精简到 1075 行,提取以下内容:

- 新增 utils/editor-history.ts:历史操作处理函数(add/remove/update)
- utils/editor.ts 新增:resolveSelectedNode、toggleFixedPosition、
  calcMoveStyle、calcAlignCenterStyle、calcLayerTargetIndex、
  editorNodeMergeCustomizer、collectRelatedNodes、classifyDragSources
- type.ts 新增:EditorEvents、canUsePluginMethods、AsyncMethodName
- 补充完整的单元测试覆盖所有新增工具函数

Made-with: Cursor
This commit is contained in:
roymondchen 2026-04-03 16:27:55 +08:00
parent a7274198bf
commit 0c2f2fd2b5
6 changed files with 1186 additions and 324 deletions

View File

@ -17,23 +17,13 @@
*/
import { reactive, toRaw } from 'vue';
import { cloneDeep, get, isObject, mergeWith, uniq } from 'lodash-es';
import type { Writable } from 'type-fest';
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NodeType, Target, Watcher } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage';
import {
calcValueByFontsize,
getElById,
getNodeInfo,
getNodePath,
isNumber,
isPage,
isPageFragment,
isPop,
} from '@tmagic/utils';
import { getNodeInfo, getNodePath, isPage, isPageFragment } from '@tmagic/utils';
import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props';
@ -42,6 +32,8 @@ import storageService, { Protocol } from '@editor/services/storage';
import type {
AddMNode,
AsyncHookPlugin,
AsyncMethodName,
EditorEvents,
EditorNodeInfo,
HistoryOpType,
PastePosition,
@ -49,63 +41,30 @@ import type {
StoreState,
StoreStateKey,
} from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
import {
change2Fixed,
calcAlignCenterStyle,
calcLayerTargetIndex,
calcMoveStyle,
classifyDragSources,
collectRelatedNodes,
COPY_STORAGE_KEY,
Fixed2Other,
editorNodeMergeCustomizer,
fixNodePosition,
getInitPositionStyle,
getNodeIndex,
getPageFragmentList,
getPageList,
moveItemsInContainer,
resolveSelectedNode,
setChildrenLayout,
setLayout,
toggleFixedPosition,
} from '@editor/utils/editor';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
import { beforePaste, getAddParent } from '@editor/utils/operator';
export interface EditorEvents {
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
select: [node: MNode | null];
add: [nodes: MNode[]];
remove: [nodes: MNode[]];
update: [nodes: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }[]];
'move-layer': [offset: number | LayerOffset];
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
'history-change': [data: MPage | MPageFragment];
}
const canUsePluginMethods = {
async: [
'getLayout',
'highlight',
'select',
'multiSelect',
'doAdd',
'add',
'doRemove',
'remove',
'doUpdate',
'update',
'sort',
'copy',
'paste',
'doPaste',
'doAlignCenter',
'alignCenter',
'moveLayer',
'moveToContainer',
'dragTo',
'undo',
'redo',
'move',
] as const,
sync: [],
};
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,
@ -554,21 +513,9 @@ class Editor extends BaseService {
const node = toRaw(info.node);
let newConfig = await this.toggleFixedPosition(toRaw(config), node, root);
let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
newConfig = mergeWith(cloneDeep(node), newConfig, (objValue, srcValue, key, object: any, source: any) => {
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
return '';
}
if (isObject(srcValue) && Array.isArray(objValue)) {
// 原来的配置是数组,新的配置是对象,则直接使用新的值
return srcValue;
}
if (Array.isArray(srcValue)) {
return srcValue;
}
});
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
if (!newConfig.type) throw new Error('配置缺少type值');
@ -703,31 +650,8 @@ class Editor extends BaseService {
public copyWithRelated(config: MNode | MNode[], collectorOptions?: TargetOptions): void {
const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
// 初始化复制组件相关的依赖收集器
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
const customTarget = new Target({
...collectorOptions,
});
const coperWatcher = new Watcher();
coperWatcher.addTarget(customTarget);
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
const node = this.getNodeById(nodeId);
if (!node) return;
customTarget!.deps[nodeId].keys.forEach((key) => {
const relateNodeId = get(node, key);
const isExist = copyNodes.find((node) => node.id === relateNodeId);
if (!isExist) {
const relateNode = this.getNodeById(relateNodeId);
if (relateNode) {
copyNodes.push(relateNode);
}
}
});
});
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
}
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
@ -772,32 +696,16 @@ class Editor extends BaseService {
public async doAlignCenter(config: MNode): Promise<MNode> {
const parent = this.getParentById(config.id);
if (!parent) throw new Error('找不到父节点');
const node = cloneDeep(toRaw(config));
const layout = await this.getLayout(parent, node);
if (layout === Layout.RELATIVE) {
return config;
}
const doc = this.get('stage')?.renderer?.contentWindow?.document;
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
if (!node.style) return config;
const stage = this.get('stage');
const doc = stage?.renderer?.contentWindow?.document;
if (doc) {
const el = getElById()(doc, node.id);
const parentEl = layout === Layout.FIXED ? doc.body : el?.offsetParent;
if (parentEl && el) {
node.style.left = calcValueByFontsize(doc, (parentEl.clientWidth - el.clientWidth) / 2);
node.style.right = '';
}
} else if (parent.style && isNumber(parent.style?.width) && isNumber(node.style?.width)) {
node.style.left = (parent.style.width - node.style.width) / 2;
node.style.right = '';
}
if (!newStyle) return config;
node.style = newStyle;
return node;
}
@ -842,18 +750,9 @@ class Editor extends BaseService {
const brothers: MNode[] = parent.items || [];
const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`);
// 流式布局与绝对定位布局操作的相反的
const layout = await this.getLayout(parent, node);
const isRelative = layout === Layout.RELATIVE;
let offsetIndex: number;
if (offset === LayerOffset.TOP) {
offsetIndex = isRelative ? 0 : brothers.length;
} else if (offset === LayerOffset.BOTTOM) {
offsetIndex = isRelative ? brothers.length : 0;
} else {
offsetIndex = index + (isRelative ? -offset : offset);
}
const offsetIndex = calcLayerTargetIndex(index, offset, brothers.length, isRelative);
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
return;
@ -946,7 +845,6 @@ class Editor extends BaseService {
const configs = Array.isArray(config) ? config : [config];
const beforeSnapshots = new Map<string, MNode>();
// 收集所有受影响父节点的变更前快照
for (const cfg of configs) {
const { parent } = this.getNodeInfo(cfg.id, false);
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
@ -957,51 +855,27 @@ class Editor extends BaseService {
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
}
const sourceIndicesInTargetParent: number[] = [];
const sourceOutTargetParent: MNode[] = [];
const newLayout = await this.getLayout(targetParent);
const { sameParentIndices, crossParentConfigs, aborted } = classifyDragSources(configs, targetParent, (id, raw) =>
this.getNodeInfo(id, raw),
);
if (aborted) return;
// eslint-disable-next-line no-restricted-syntax
forConfigs: for (const config of configs) {
const { parent, node: curNode } = this.getNodeInfo(config.id, false);
if (!parent || !curNode) {
continue;
}
const path = getNodePath(curNode.id, parent.items);
for (const node of path) {
if (targetParent.id === node.id) {
continue forConfigs;
}
}
const index = getNodeIndex(curNode.id, parent);
if (parent.id === targetParent.id) {
if (typeof index !== 'number' || index === -1) {
return;
}
sourceIndicesInTargetParent.push(index);
} else {
const layout = await this.getLayout(parent);
if (newLayout !== layout) {
setLayout(config, newLayout);
}
parent.items?.splice(index, 1);
sourceOutTargetParent.push(config);
this.addModifiedNodeId(parent.id);
for (const { config: crossConfig, parent } of crossParentConfigs) {
const layout = await this.getLayout(parent);
if (newLayout !== layout) {
setLayout(crossConfig, newLayout);
}
const index = getNodeIndex(crossConfig.id, parent);
parent.items?.splice(index, 1);
this.addModifiedNodeId(parent.id);
}
moveItemsInContainer(sourceIndicesInTargetParent, targetParent, targetIndex);
moveItemsInContainer(sameParentIndices, targetParent, targetIndex);
sourceOutTargetParent.forEach((config, index) => {
targetParent.items?.splice(targetIndex + index, 0, config);
this.addModifiedNodeId(config.id);
crossParentConfigs.forEach(({ config: crossConfig }, index) => {
targetParent.items?.splice(targetIndex + index, 0, crossConfig);
this.addModifiedNodeId(crossConfig.id);
});
const page = this.get('page');
@ -1056,47 +930,10 @@ class Editor extends BaseService {
const node = toRaw(this.get('node'));
if (!node || isPage(node)) return;
const { style, id, type } = node;
if (!style || !['absolute', 'fixed'].includes(style.position)) return;
const newStyle = calcMoveStyle(node.style || {}, left, top);
if (!newStyle) return;
const update = (style: { [key: string]: any }) =>
this.update({
id,
type,
style,
});
if (top) {
if (isNumber(style.top)) {
update({
...style,
top: Number(style.top) + Number(top),
bottom: '',
});
} else if (isNumber(style.bottom)) {
update({
...style,
bottom: Number(style.bottom) - Number(top),
top: '',
});
}
}
if (left) {
if (isNumber(style.left)) {
update({
...style,
left: Number(style.left) + Number(left),
right: '',
});
} else if (isNumber(style.right)) {
update({
...style,
right: Number(style.right) - Number(left),
left: '',
});
}
}
await this.update({ id: node.id, type: node.type, style: newStyle });
}
public resetState() {
@ -1183,88 +1020,26 @@ class Editor extends BaseService {
const stage = this.get('stage');
if (!root) return;
const ctx: HistoryOpContext = {
root,
stage,
getNodeById: (id, raw) => this.getNodeById(id, raw),
getNodeInfo: (id, raw) => this.getNodeInfo(id, raw),
setRoot: (r) => this.set('root', r),
setPage: (p) => this.set('page', p),
getPage: () => this.get('page'),
};
switch (step.opType) {
case 'add': {
if (reverse) {
for (const node of step.nodes ?? []) {
const parent = this.getNodeById(step.parentId!, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
}
} else {
const parent = this.getNodeById(step.parentId!, false) as MContainer;
if (parent?.items) {
for (const node of step.nodes ?? []) {
const idx = step.indexMap?.[node.id] ?? parent.items.length;
parent.items.splice(idx, 0, cloneDeep(node));
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
case 'add':
await applyHistoryAddOp(step, reverse, ctx);
break;
}
case 'remove': {
if (reverse) {
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const parent = this.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
parent.items.splice(index, 0, cloneDeep(node));
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
}
} else {
for (const { node, parentId } of step.removedItems ?? []) {
const parent = this.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
}
}
case 'remove':
await applyHistoryRemoveOp(step, reverse, ctx);
break;
}
case 'update': {
const items = step.updatedItems ?? [];
for (const { oldNode, newNode } of items) {
const config = reverse ? oldNode : newNode;
if (config.type === NodeType.ROOT) {
this.set('root', cloneDeep(config) as MApp);
continue;
}
const info = this.getNodeInfo(config.id, false);
if (!info.parent) continue;
const idx = getNodeIndex(config.id, info.parent);
if (typeof idx !== 'number' || idx === -1) continue;
info.parent.items![idx] = cloneDeep(config);
if (isPage(config) || isPageFragment(config)) {
this.set('page', config as MPage | MPageFragment);
}
}
const curPage = this.get('page');
if (stage && curPage) {
await stage.update({
config: cloneDeep(toRaw(curPage)),
parentId: root.id,
root: cloneDeep(toRaw(root)),
});
}
case 'update':
await applyHistoryUpdateOp(step, reverse, ctx);
break;
}
}
this.set('modifiedNodeIds', step.modifiedNodeIds);
@ -1290,42 +1065,8 @@ class Editor extends BaseService {
this.isHistoryStateChange = false;
}
private async toggleFixedPosition(dist: MNode, src: MNode, root: MApp) {
const newConfig = cloneDeep(dist);
if (!isPop(src) && newConfig.style?.position) {
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
newConfig.style = change2Fixed(newConfig, root);
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
newConfig.style = await Fixed2Other(newConfig, root, this.getLayout);
}
}
return newConfig;
}
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
let id: Id;
if (typeof config === 'string' || typeof config === 'number') {
id = config;
} else {
id = config.id;
}
if (!id) {
throw new Error('没有ID无法选中');
}
const { node, parent, page } = this.getNodeInfo(id);
if (!node) throw new Error('获取不到组件信息');
if (node.id === this.state.root?.id) {
throw new Error('不能选根节点');
}
return {
node,
parent,
page,
};
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
}
}

View File

@ -20,10 +20,10 @@ import type { Component } from 'vue';
import type EventEmitter from 'events';
import type * as Monaco from 'monaco-editor';
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
import type { PascalCasedProperties } from 'type-fest';
import type { PascalCasedProperties, Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import type { FormConfig, TableColumnConfig } from '@tmagic/form';
import type { ChangeRecord, FormConfig, TableColumnConfig } from '@tmagic/form';
import type StageCore from '@tmagic/stage';
import type {
ContainerHighlightType,
@ -727,3 +727,44 @@ export type CustomContentMenuFunction = (
menus: (MenuButton | MenuComponent)[],
type: 'layer' | 'data-source' | 'viewer' | 'code-block',
) => (MenuButton | MenuComponent)[];
export interface EditorEvents {
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
select: [node: MNode | null];
add: [nodes: MNode[]];
remove: [nodes: MNode[]];
update: [nodes: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }[]];
'move-layer': [offset: number | LayerOffset];
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
'history-change': [data: MPage | MPageFragment];
}
export const canUsePluginMethods = {
async: [
'getLayout',
'highlight',
'select',
'multiSelect',
'doAdd',
'add',
'doRemove',
'remove',
'doUpdate',
'update',
'sort',
'copy',
'paste',
'doPaste',
'doAlignCenter',
'alignCenter',
'moveLayer',
'moveToContainer',
'dragTo',
'undo',
'redo',
'move',
] as const,
sync: [],
};
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;

View File

@ -0,0 +1,138 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { toRaw } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type StageCore from '@tmagic/stage';
import { isPage, isPageFragment } from '@tmagic/utils';
import type { EditorNodeInfo, StepValue } from '@editor/type';
import { getNodeIndex } from '@editor/utils/editor';
export interface HistoryOpContext {
root: MApp;
stage: StageCore | null;
getNodeById(id: Id, raw?: boolean): MNode | null;
getNodeInfo(id: Id, raw?: boolean): EditorNodeInfo;
setRoot(root: MApp): void;
setPage(page: MPage | MPageFragment): void;
getPage(): MPage | MPageFragment | null;
}
/**
* add
* reverse=true
* reverse=false
*/
export async function applyHistoryAddOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
for (const node of step.nodes ?? []) {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
}
} else {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (parent?.items) {
for (const node of step.nodes ?? []) {
const idx = step.indexMap?.[node.id] ?? parent.items.length;
parent.items.splice(idx, 0, cloneDeep(node));
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
}
/**
* remove
* reverse=true
* reverse=false
*/
export async function applyHistoryRemoveOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
parent.items.splice(index, 0, cloneDeep(node));
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
}
} else {
for (const { node, parentId } of step.removedItems ?? []) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
}
}
}
/**
* update
* reverse=true oldNode
* reverse=false newNode
*/
export async function applyHistoryUpdateOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
const items = step.updatedItems ?? [];
for (const { oldNode, newNode } of items) {
const config = reverse ? oldNode : newNode;
if (config.type === NodeType.ROOT) {
ctx.setRoot(cloneDeep(config) as MApp);
continue;
}
const info = ctx.getNodeInfo(config.id, false);
if (!info.parent) continue;
const idx = getNodeIndex(config.id, info.parent);
if (typeof idx !== 'number' || idx === -1) continue;
info.parent.items![idx] = cloneDeep(config);
if (isPage(config) || isPageFragment(config)) {
ctx.setPage(config as MPage | MPageFragment);
}
}
const curPage = ctx.getPage();
if (stage && curPage) {
await stage.update({
config: cloneDeep(toRaw(curPage)),
parentId: root.id,
root: cloneDeep(toRaw(root)),
});
}
}

View File

@ -17,12 +17,13 @@
*/
import { detailedDiff } from 'deep-object-diff';
import { isObject } from 'lodash-es';
import { cloneDeep, get, isObject } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import { NODE_CONDS_KEY, NodeType } from '@tmagic/core';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
import type StageCore from '@tmagic/stage';
import { isFixed } from '@tmagic/stage';
import {
calcValueByFontsize,
getElById,
@ -34,7 +35,8 @@ import {
isValueIncludeDataSource,
} from '@tmagic/utils';
import { Layout } from '@editor/type';
import type { EditorNodeInfo } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
@ -436,3 +438,246 @@ export const buildChangeRecords = (value: any, basePath: string) => {
return changeRecords;
};
/**
* ID解析出选中节点信息
* @param config ID
* @param getNodeInfoFn
* @param rootId ID
* @returns nodeparentpage
*/
export const resolveSelectedNode = (
config: MNode | Id,
getNodeInfoFn: (id: Id) => EditorNodeInfo,
rootId?: Id,
): EditorNodeInfo => {
const id: Id = typeof config === 'string' || typeof config === 'number' ? config : config.id;
if (!id) {
throw new Error('没有ID无法选中');
}
const { node, parent, page } = getNodeInfoFn(id);
if (!node) throw new Error('获取不到组件信息');
if (node.id === rootId) throw new Error('不能选根节点');
return { node, parent, page };
};
/**
* fixed
* fixed fixed
* @param dist
* @param src
* @param root
* @param getLayoutFn
* @returns
*/
export const toggleFixedPosition = async (
dist: MNode,
src: MNode,
root: MApp,
getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>,
): Promise<MNode> => {
const newConfig = cloneDeep(dist);
if (!isPop(src) && newConfig.style?.position) {
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
newConfig.style = change2Fixed(newConfig, root);
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
newConfig.style = await Fixed2Other(newConfig, root, getLayoutFn);
}
}
return newConfig;
};
/**
*
* absolute fixed top/left bottom/right
* @param style
* @param left
* @param top
* @returns null
*/
export const calcMoveStyle = (style: Record<string, any>, left: number, top: number): Record<string, any> | null => {
if (!style || !['absolute', 'fixed'].includes(style.position)) return null;
const newStyle: Record<string, any> = { ...style };
if (top) {
if (isNumber(style.top)) {
newStyle.top = Number(style.top) + Number(top);
newStyle.bottom = '';
} else if (isNumber(style.bottom)) {
newStyle.bottom = Number(style.bottom) - Number(top);
newStyle.top = '';
}
}
if (left) {
if (isNumber(style.left)) {
newStyle.left = Number(style.left) + Number(left);
newStyle.right = '';
} else if (isNumber(style.right)) {
newStyle.right = Number(style.right) - Number(left);
newStyle.left = '';
}
}
return newStyle;
};
/**
*
* relative DOM 退 width
* @param node
* @param parent
* @param layout
* @param doc document DOM
* @returns null
*/
export const calcAlignCenterStyle = (
node: MNode,
parent: MContainer,
layout: Layout,
doc?: Document,
): Record<string, any> | null => {
if (layout === Layout.RELATIVE || !node.style) return null;
const style = { ...node.style };
if (doc) {
const el = getElById()(doc, node.id);
const parentEl = layout === Layout.FIXED ? doc.body : el?.offsetParent;
if (parentEl && el) {
style.left = calcValueByFontsize(doc, (parentEl.clientWidth - el.clientWidth) / 2);
style.right = '';
}
} else if (parent.style && isNumber(parent.style?.width) && isNumber(node.style?.width)) {
style.left = (parent.style.width - node.style.width) / 2;
style.right = '';
}
return style;
};
/**
*
* "上移""上移"
* @param currentIndex
* @param offset LayerOffset.TOP / LayerOffset.BOTTOM
* @param brothersLength
* @param isRelative
* @returns
*/
export const calcLayerTargetIndex = (
currentIndex: number,
offset: number | LayerOffset,
brothersLength: number,
isRelative: boolean,
): number => {
if (offset === LayerOffset.TOP) {
return isRelative ? 0 : brothersLength;
}
if (offset === LayerOffset.BOTTOM) {
return isRelative ? brothersLength : 0;
}
return currentIndex + (isRelative ? -(offset as number) : (offset as number));
};
/**
* mergeWith
* - undefined source key
* - 使
* -
*/
export const editorNodeMergeCustomizer = (objValue: any, srcValue: any, key: string, _object: any, source: any) => {
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
return '';
}
if (isObject(srcValue) && Array.isArray(objValue)) {
return srcValue;
}
if (Array.isArray(srcValue)) {
return srcValue;
}
};
/**
* copyNodes
* @param copyNodes
* @param collectorOptions
* @param getNodeById ID
*/
export const collectRelatedNodes = (
copyNodes: MNode[],
collectorOptions: TargetOptions,
getNodeById: (id: Id) => MNode | null,
): void => {
const customTarget = new Target({ ...collectorOptions });
const coperWatcher = new Watcher();
coperWatcher.addTarget(customTarget);
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
const node = getNodeById(nodeId);
if (!node) return;
customTarget.deps[nodeId].keys.forEach((key) => {
const relateNodeId = get(node, key);
const isExist = copyNodes.find((n) => n.id === relateNodeId);
if (!isExist) {
const relateNode = getNodeById(relateNodeId);
if (relateNode) {
copyNodes.push(relateNode);
}
}
});
});
};
export interface DragClassification {
sameParentIndices: number[];
crossParentConfigs: { config: MNode; parent: MContainer }[];
/** 当同父容器节点索引异常时置为 true调用方应中止拖拽操作 */
aborted: boolean;
}
/**
* vs
* @param configs
* @param targetParent
* @param getNodeInfo
* @returns
*/
export const classifyDragSources = (
configs: MNode[],
targetParent: MContainer,
getNodeInfo: (id: Id, raw?: boolean) => EditorNodeInfo,
): DragClassification => {
const sameParentIndices: number[] = [];
const crossParentConfigs: { config: MNode; parent: MContainer }[] = [];
for (const config of configs) {
const { parent, node: curNode } = getNodeInfo(config.id, false);
if (!parent || !curNode) continue;
const path = getNodePath(curNode.id, parent.items);
if (path.some((node) => `${targetParent.id}` === `${node.id}`)) continue;
const index = getNodeIndex(curNode.id, parent);
if (`${parent.id}` === `${targetParent.id}`) {
if (typeof index !== 'number' || index === -1) {
return { sameParentIndices, crossParentConfigs, aborted: true };
}
sameParentIndices.push(index);
} else {
crossParentConfigs.push({ config, parent });
}
}
return { sameParentIndices, crossParentConfigs, aborted: false };
};

View File

@ -0,0 +1,245 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, test, vi } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { StepValue } from '@editor/type';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
const makePage = (): MContainer => ({
id: 'page_1',
type: NodeType.PAGE,
items: [
{ id: 'n1', type: 'text' },
{ id: 'n2', type: 'button' },
],
});
const makeRoot = (page: MContainer): MApp => ({
id: 'app_1',
type: NodeType.ROOT,
items: [page],
});
const makeCtx = (root: MApp): HistoryOpContext => {
const page = root.items[0] as MContainer;
return {
root,
stage: {
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
} as any,
getNodeById: (id: any) => {
if (`${id}` === `${root.id}`) return root as unknown as MNode;
if (`${id}` === `${page.id}`) return page as unknown as MNode;
return page.items.find((n) => `${n.id}` === `${id}`) ?? null;
},
getNodeInfo: (id: any) => {
if (`${id}` === `${page.id}`) {
return { node: page as unknown as MNode, parent: root as unknown as MContainer, page: page as any };
}
const node = page.items.find((n) => `${n.id}` === `${id}`);
return { node: node ?? null, parent: node ? page : null, page: page as any };
},
setRoot: vi.fn(),
setPage: vi.fn(),
getPage: () => page as any,
};
};
describe('applyHistoryAddOp', () => {
test('撤销 add从父节点移除已添加的节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['n1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'n1', type: 'text' }],
parentId: 'page_1',
};
expect(page.items).toHaveLength(2);
await applyHistoryAddOp(step, true, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
test('重做 add重新添加节点到父节点', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['new1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'new1', type: 'text' }],
parentId: 'page_1',
indexMap: { new1: 0 },
};
await applyHistoryAddOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('new1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
});
describe('applyHistoryRemoveOp', () => {
test('撤销 remove将已删除节点按原位置重新插入', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'n2', type: 'button' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: ['n1'],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
await applyHistoryRemoveOp(step, true, ctx);
expect(page.items).toHaveLength(2);
expect(page.items[0].id).toBe('n1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
test('重做 remove再次删除节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
expect(page.items).toHaveLength(2);
await applyHistoryRemoveOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
});
describe('applyHistoryUpdateOp', () => {
test('撤销 update将节点恢复为 oldNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(page.items[0].text).toBe('before');
expect(ctx.stage!.update).toHaveBeenCalled();
});
test('重做 update将节点更新为 newNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(page.items[0].text).toBe('after');
});
test('update ROOT 类型调用 setRoot', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'app_1', type: NodeType.ROOT, items: [] } as any,
newNode: { id: 'app_1', type: NodeType.ROOT, items: [page] } as any,
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(ctx.setRoot).toHaveBeenCalled();
});
test('update 页面节点调用 setPage', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const updatedPage = { ...page, name: 'renamed' };
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: page as any,
newNode: updatedPage as any,
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(ctx.setPage).toHaveBeenCalled();
});
});

View File

@ -17,8 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { EditorNodeInfo } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
import * as editor from '@editor/utils/editor';
describe('util form', () => {
@ -305,3 +308,452 @@ describe('buildChangeRecords', () => {
]);
});
});
// ===== 以下为新提取的工具函数测试 =====
const mockRoot: MApp = {
id: 'app_1',
type: NodeType.ROOT,
items: [
{
id: 'page_1',
type: NodeType.PAGE,
name: 'index',
style: { position: 'relative', width: 375 },
items: [
{
id: 'node_1',
type: 'text',
style: { position: 'absolute', top: 10, left: 20, width: 100 },
},
{
id: 'node_2',
type: 'button',
style: { position: 'absolute', bottom: 50, right: 30 },
},
{
id: 'node_3',
type: 'image',
style: { position: 'relative', top: 0, left: 0 },
},
],
},
],
};
const mockGetNodeInfo = (id: string | number): EditorNodeInfo => {
const page = mockRoot.items[0];
if (`${id}` === `${mockRoot.id}`) {
return { node: mockRoot as unknown as MNode, parent: null, page: null };
}
if (`${id}` === `${page.id}`) {
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any };
}
const items = (page as MContainer).items || [];
const node = items.find((n: MNode) => `${n.id}` === `${id}`);
if (node) {
return { node, parent: page as MContainer, page: page as any };
}
return { node: null, parent: null, page: null };
};
describe('resolveSelectedNode', () => {
test('传入数字ID正常返回节点信息', () => {
const result = editor.resolveSelectedNode('node_1', mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('node_1');
expect(result.parent?.id).toBe('page_1');
expect(result.page?.id).toBe('page_1');
});
test('传入节点配置对象,正常返回节点信息', () => {
const config: MNode = { id: 'node_2', type: 'button' };
const result = editor.resolveSelectedNode(config, mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('node_2');
});
test('传入页面ID正常返回页面信息', () => {
const result = editor.resolveSelectedNode('page_1', mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('page_1');
});
test('传入空ID抛出错误', () => {
expect(() => editor.resolveSelectedNode({ id: '', type: 'text' }, mockGetNodeInfo)).toThrow('没有ID无法选中');
});
test('传入不存在的ID抛出错误', () => {
expect(() => editor.resolveSelectedNode('not_exist', mockGetNodeInfo)).toThrow('获取不到组件信息');
});
test('传入根节点ID抛出错误', () => {
expect(() => editor.resolveSelectedNode('app_1', mockGetNodeInfo, mockRoot.id)).toThrow('不能选根节点');
});
test('不传rootId时不校验根节点', () => {
const result = editor.resolveSelectedNode('app_1', mockGetNodeInfo);
expect(result.node?.id).toBe('app_1');
});
});
describe('toggleFixedPosition', () => {
const getLayoutFn = async () => Layout.ABSOLUTE;
test('非fixed变为fixed调用change2Fixed', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('fixed');
expect(result).not.toBe(dist);
});
test('fixed变为非fixed调用Fixed2Other', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('absolute');
});
test('定位未变化,不修改样式', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 30, left: 40 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.top).toBe(30);
expect(result.style?.left).toBe(40);
});
test('pop类型节点不做处理', async () => {
const src: MNode = {
id: 'node_1',
type: 'pop',
style: { position: 'absolute', top: 10, left: 20 },
name: 'pop',
};
const dist: MNode = { id: 'node_1', type: 'pop', style: { position: 'fixed', top: 10, left: 20 }, name: 'pop' };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('fixed');
});
test('目标节点无position属性不做处理', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute' } };
const dist: MNode = { id: 'node_1', type: 'text', style: { width: 100 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBeUndefined();
});
test('返回深拷贝,不修改原对象', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result).not.toBe(dist);
expect(dist.style?.top).toBe(20);
});
});
describe('calcMoveStyle', () => {
test('absolute定位向下移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 0, 5);
expect(result).toEqual({ position: 'absolute', top: 15, left: 20, bottom: '' });
});
test('absolute定位向右移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 5, 0);
expect(result).toEqual({ position: 'absolute', top: 10, left: 25, right: '' });
});
test('absolute定位同时向下和向右移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 3, 7);
expect(result).toEqual({ position: 'absolute', top: 17, left: 23, bottom: '', right: '' });
});
test('fixed定位正常移动', () => {
const style = { position: 'fixed', top: 100, left: 200 };
const result = editor.calcMoveStyle(style, -10, -20);
expect(result).toEqual({ position: 'fixed', top: 80, left: 190, bottom: '', right: '' });
});
test('使用bottom定位时向下移动减小bottom', () => {
const style = { position: 'absolute', bottom: 50, left: 20 };
const result = editor.calcMoveStyle(style, 0, 10);
expect(result?.bottom).toBe(40);
expect(result?.top).toBe('');
});
test('使用right定位时向右移动减小right', () => {
const style = { position: 'absolute', top: 10, right: 30 };
const result = editor.calcMoveStyle(style, 10, 0);
expect(result?.right).toBe(20);
expect(result?.left).toBe('');
});
test('relative定位返回null', () => {
const style = { position: 'relative', top: 0, left: 0 };
const result = editor.calcMoveStyle(style, 10, 10);
expect(result).toBeNull();
});
test('无position属性返回null', () => {
const style = { width: 100 };
const result = editor.calcMoveStyle(style, 10, 10);
expect(result).toBeNull();
});
test('空样式对象返回null', () => {
const result = editor.calcMoveStyle({}, 10, 10);
expect(result).toBeNull();
});
test('偏移量为0不修改样式', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 0, 0);
expect(result).toEqual({ position: 'absolute', top: 10, left: 20 });
});
test('不修改原对象', () => {
const style = { position: 'absolute', top: 10, left: 20 };
editor.calcMoveStyle(style, 5, 5);
expect(style.top).toBe(10);
expect(style.left).toBe(20);
});
});
describe('calcAlignCenterStyle', () => {
test('absolute布局通过配置中的width计算居中', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(137.5);
expect(result?.right).toBe('');
});
test('relative布局返回null', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.RELATIVE);
expect(result).toBeNull();
});
test('节点无style返回null', () => {
const node: MNode = { id: 'n1', type: 'text' };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result).toBeNull();
});
test('父节点无style不修改', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
const parent = { id: 'p1', type: NodeType.PAGE, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(10);
});
test('父节点width非数字不修改left', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
const parent = {
id: 'p1',
type: NodeType.PAGE,
style: { width: '100%' },
items: [],
} as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(10);
});
test('不修改原节点style', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(node.style?.left).toBe(0);
});
});
describe('calcLayerTargetIndex', () => {
test('绝对定位向上移动1层', () => {
const result = editor.calcLayerTargetIndex(2, 1, 5, false);
expect(result).toBe(3);
});
test('绝对定位向下移动1层', () => {
const result = editor.calcLayerTargetIndex(2, -1, 5, false);
expect(result).toBe(1);
});
test('流式布局向上移动1层索引减小', () => {
const result = editor.calcLayerTargetIndex(2, 1, 5, true);
expect(result).toBe(1);
});
test('流式布局向下移动1层索引增大', () => {
const result = editor.calcLayerTargetIndex(2, -1, 5, true);
expect(result).toBe(3);
});
test('绝对定位,置顶', () => {
const result = editor.calcLayerTargetIndex(2, LayerOffset.TOP, 5, false);
expect(result).toBe(5);
});
test('绝对定位,置底', () => {
const result = editor.calcLayerTargetIndex(2, LayerOffset.BOTTOM, 5, false);
expect(result).toBe(0);
});
test('流式布局,置顶(索引最小)', () => {
const result = editor.calcLayerTargetIndex(3, LayerOffset.TOP, 5, true);
expect(result).toBe(0);
});
test('流式布局,置底(索引最大)', () => {
const result = editor.calcLayerTargetIndex(1, LayerOffset.BOTTOM, 5, true);
expect(result).toBe(5);
});
test('偏移量为0索引不变', () => {
const result = editor.calcLayerTargetIndex(2, 0, 5, false);
expect(result).toBe(2);
});
});
describe('editorNodeMergeCustomizer', () => {
test('undefined 且 source 拥有该 key 时返回空字符串', () => {
const source = { name: undefined };
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, source);
expect(result).toBe('');
});
test('source 不拥有该 key 时返回 undefined使用默认合并', () => {
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, {});
expect(result).toBeUndefined();
});
test('原来是数组,新值是对象,使用新值', () => {
const srcValue = { a: 1 };
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
expect(result).toBe(srcValue);
});
test('新值是数组,直接替换', () => {
const srcValue = [3, 4];
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
expect(result).toBe(srcValue);
});
test('都是普通值,返回 undefined使用默认合并', () => {
const result = editor.editorNodeMergeCustomizer('old', 'new', 'key', {}, {});
expect(result).toBeUndefined();
});
});
describe('classifyDragSources', () => {
const makeTree = (): { root: MApp; getNodeInfo: (id: any, raw?: boolean) => EditorNodeInfo } => {
const child1: MNode = { id: 'c1', type: 'text' };
const child2: MNode = { id: 'c2', type: 'text' };
const child3: MNode = { id: 'c3', type: 'text' };
const container1: MContainer = {
id: 'cont1',
type: NodeType.CONTAINER,
items: [child1, child2],
};
const container2: MContainer = {
id: 'cont2',
type: NodeType.CONTAINER,
items: [child3],
};
const page: any = {
id: 'page_1',
type: NodeType.PAGE,
items: [container1, container2],
};
const root: MApp = { id: 'app', type: NodeType.ROOT, items: [page] };
const getNodeInfo = (id: any): EditorNodeInfo => {
if (`${id}` === 'c1' || `${id}` === 'c2') {
return {
node: container1.items.find((n) => `${n.id}` === `${id}`) ?? null,
parent: container1,
page,
};
}
if (`${id}` === 'c3') {
return { node: child3, parent: container2, page };
}
if (`${id}` === 'cont1') {
return { node: container1, parent: page, page };
}
if (`${id}` === 'cont2') {
return { node: container2, parent: page, page };
}
return { node: null, parent: null, page: null };
};
return { root, getNodeInfo };
};
test('同父容器内拖拽,返回 sameParentIndices', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(0);
});
test('跨容器拖拽,返回 crossParentConfigs', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c3', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toHaveLength(0);
expect(result.crossParentConfigs).toHaveLength(1);
expect(result.crossParentConfigs[0].config.id).toBe('c3');
});
test('混合拖拽:同容器+跨容器', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources(
[
{ id: 'c1', type: 'text' },
{ id: 'c3', type: 'text' },
],
targetParent,
getNodeInfo,
);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(1);
});
test('节点不存在时跳过', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'nonexistent', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toHaveLength(0);
expect(result.crossParentConfigs).toHaveLength(0);
});
test('目标容器在节点路径上时跳过(防止循环嵌套)', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, (id: any) => {
if (`${id}` === 'c1') {
return {
node: { id: 'c1', type: 'text' },
parent: targetParent,
page: { id: 'page_1', type: NodeType.PAGE, items: [] } as any,
};
}
return { node: null, parent: null, page: null };
});
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(0);
});
});