mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-04-20 19:38:05 +00:00
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:
parent
a7274198bf
commit
0c2f2fd2b5
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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']>;
|
||||
|
||||
138
packages/editor/src/utils/editor-history.ts
Normal file
138
packages/editor/src/utils/editor-history.ts
Normal 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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 选中节点的完整信息(node、parent、page)
|
||||
*/
|
||||
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 };
|
||||
};
|
||||
|
||||
245
packages/editor/tests/unit/utils/editor-history.spec.ts
Normal file
245
packages/editor/tests/unit/utils/editor-history.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user