From 0c2f2fd2b5f674e7ea62edaf92357b39878a8abf Mon Sep 17 00:00:00 2001 From: roymondchen Date: Fri, 3 Apr 2026 16:27:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(editor):=20=E6=8B=86=E5=88=86=20editor?= =?UTF-8?q?=20service=EF=BC=8C=E6=8F=90=E5=8F=96=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=87=8F=E5=B0=91=E6=96=87=E4=BB=B6=E8=A1=8C?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 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 --- packages/editor/src/services/editor.ts | 377 +++------------ packages/editor/src/type.ts | 45 +- packages/editor/src/utils/editor-history.ts | 138 ++++++ packages/editor/src/utils/editor.ts | 253 +++++++++- .../tests/unit/utils/editor-history.spec.ts | 245 ++++++++++ .../editor/tests/unit/utils/editor.spec.ts | 452 ++++++++++++++++++ 6 files changed, 1186 insertions(+), 324 deletions(-) create mode 100644 packages/editor/src/utils/editor-history.ts create mode 100644 packages/editor/tests/unit/utils/editor-history.spec.ts diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 47784167..240bb5a8 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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 { 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(); - // 收集所有受影响父节点的变更前快照 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); } } diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 7a4dc8cc..3f8fb0fc 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -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']>; diff --git a/packages/editor/src/utils/editor-history.ts b/packages/editor/src/utils/editor-history.ts new file mode 100644 index 00000000..45ad80e6 --- /dev/null +++ b/packages/editor/src/utils/editor-history.ts @@ -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 { + 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 { + 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 { + 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)), + }); + } +} diff --git a/packages/editor/src/utils/editor.ts b/packages/editor/src/utils/editor.ts index 9dbb161c..5110b753 100644 --- a/packages/editor/src/utils/editor.ts +++ b/packages/editor/src/utils/editor.ts @@ -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, +): Promise => { + 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, left: number, top: number): Record | null => { + if (!style || !['absolute', 'fixed'].includes(style.position)) return null; + + const newStyle: Record = { ...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 | 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 }; +}; diff --git a/packages/editor/tests/unit/utils/editor-history.spec.ts b/packages/editor/tests/unit/utils/editor-history.spec.ts new file mode 100644 index 00000000..64d3002a --- /dev/null +++ b/packages/editor/tests/unit/utils/editor-history.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor/tests/unit/utils/editor.spec.ts b/packages/editor/tests/unit/utils/editor.spec.ts index ee1a31db..55f272c8 100644 --- a/packages/editor/tests/unit/utils/editor.spec.ts +++ b/packages/editor/tests/unit/utils/editor.spec.ts @@ -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); + }); +});