/* * 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 { detailedDiff } from 'deep-object-diff'; import { cloneDeep, get, isObject } from 'lodash-es'; import serialize from 'serialize-javascript'; 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, getNodePath, isNumber, isPage, isPageFragment, isPop, isValueIncludeDataSource, } from '@tmagic/utils'; import type { EditorNodeInfo, StepValue } from '@editor/type'; import { LayerOffset, Layout } from '@editor/type'; export const COPY_STORAGE_KEY = '$MagicEditorCopyData'; export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode'; export const COPY_DS_STORAGE_KEY = '$MagicEditorCopyDataSource'; /** * 获取所有页面配置 * @param root DSL跟节点 * @returns 所有页面配置 */ export const getPageList = (root?: MApp | null): MPage[] => { if (!root) return []; if (!Array.isArray(root.items)) return []; return root.items.filter((item) => isPage(item)) as MPage[]; }; export const getPageFragmentList = (root?: MApp | null): MPageFragment[] => { if (!root) return []; if (!Array.isArray(root.items)) return []; return root.items.filter((item) => isPageFragment(item)) as MPageFragment[]; }; /** * 获取所有页面名称 * @param pages 所有页面配置 * @returns 所有页面名称 */ export const getPageNameList = (pages: (MPage | MPageFragment)[]): string[] => pages.map((page) => page.name || 'index'); /** * 新增页面时,生成页面名称 * @param {Object} pageNameList 所有页面名称 * @returns {string} */ export const generatePageName = (pageNameList: string[], type: NodeType.PAGE | NodeType.PAGE_FRAGMENT): string => { let pageLength = pageNameList.length; if (!pageLength) return `${type}_index`; let pageName = `${type}_${pageLength}`; while (pageNameList.includes(pageName)) { pageLength += 1; pageName = `${type}_${pageLength}`; } return pageName; }; /** * 新增页面时,生成页面名称 * @param {Object} app 所有页面配置 * @returns {string} */ export const generatePageNameByApp = (app: MApp, type: NodeType.PAGE | NodeType.PAGE_FRAGMENT): string => generatePageName(getPageNameList(type === 'page' ? getPageList(app) : getPageFragmentList(app)), type); export const getNodeIndex = (id: Id, parent: MContainer | MApp): number => { const items = parent?.items || []; return items.findIndex((item: MNode) => `${item.id}` === `${id}`); }; export const getRelativeStyle = (style: Record = {}): Record => ({ ...style, position: 'relative', top: 0, left: 0, }); const getMiddleTop = (node: MNode, parentNode: MNode, stage: StageCore | null) => { let height = node.style?.height || 0; if (!stage || typeof node.style?.top !== 'undefined' || !parentNode.style) return node.style?.top; if (!isNumber(height)) { height = 0; } const { height: parentHeight } = parentNode.style; let wrapperHeightDeal = parentHeight; if (stage.mask && stage.renderer) { // wrapperHeight 是未 calcValue的高度, 所以要将其calcValueByFontsize一下, 否则在pad or pc端计算的结果有误 const { scrollTop = 0, wrapperHeight } = stage.mask; wrapperHeightDeal = calcValueByFontsize(stage.renderer.getDocument()!, wrapperHeight); const scrollTopDeal = calcValueByFontsize(stage.renderer.getDocument()!, scrollTop); if (isPage(parentNode)) { return (wrapperHeightDeal - height) / 2 + scrollTopDeal; } } // 如果容器的元素高度大于当前视口高度的2倍, 添加的元素居中位置也会看不见, 所以要取最小值计算 return (Math.min(parentHeight, wrapperHeightDeal) - height) / 2; }; // 同时存在一对相反的定位属性(如 left/right)时只保留一个,避免元素被拉伸 const removeConflictPosition = (style: Record, primary: string, secondary: string) => { // '' / 0 / undefined / null 视为无效值 const isInvalid = (value: any) => value === '' || value === 0 || value === undefined || value === null; if (!(primary in style) || !(secondary in style)) { return; } const primaryValue = style[primary]; const secondaryValue = style[secondary]; const primaryInvalid = isInvalid(primaryValue); const secondaryInvalid = isInvalid(secondaryValue); if (primaryInvalid && !secondaryInvalid) { delete style[primary]; } else if (secondaryInvalid && !primaryInvalid) { delete style[secondary]; } else if (primaryInvalid && secondaryInvalid) { // 都是无效值时优先保留 0,否则保留 primary(left/top) if (secondaryValue === 0 && primaryValue !== 0) { delete style[primary]; } else { delete style[secondary]; } } }; export const getInitPositionStyle = (style: Record = {}, layout: Layout) => { if (layout === Layout.ABSOLUTE) { const newStyle: Record = { ...style, position: 'absolute', }; removeConflictPosition(newStyle, 'left', 'right'); removeConflictPosition(newStyle, 'top', 'bottom'); if (typeof newStyle.left === 'undefined' && typeof newStyle.right === 'undefined') { newStyle.left = 0; } return newStyle; } if (layout === Layout.RELATIVE) { return getRelativeStyle(style); } return style; }; export const setChildrenLayout = (node: MContainer, layout: Layout) => { node.items?.forEach((child: MNode) => { setLayout(child, layout); }); return node; }; export const setLayout = (node: MNode, layout: Layout) => { if (isPop(node)) return; const style = node.style || {}; // 是 fixed 不做处理 if (style.position === 'fixed') return; if (layout !== Layout.RELATIVE) { style.position = 'absolute'; } else { node.style = getRelativeStyle(style); node.style.right = 'auto'; node.style.bottom = 'auto'; } return node; }; type PositionKey = 'left' | 'top' | 'right' | 'bottom'; // 判断 style 是否显式设置了某个定位属性,使用 typeof 判断以避免将 0(如 right: 0)误判为未设置 const hasPositionValue = (style: Record | undefined, key: PositionKey): boolean => typeof style?.[key] !== 'undefined' && style?.[key] !== ''; /** * 沿节点路径累加(或抵消)某一方向上的定位偏移量 * 当路径上的祖先使用了反方向定位(例如计算 left 时祖先用了 right)或非数值定位时, * 无法简单地通过求和换算坐标,此时返回 0 表示放弃补偿、保持原值 * @param path 从根到目标节点的路径(含节点自身) * @param dir 需要累加的定位方向 * @param opposite 反方向定位属性 * @param sign change2Fixed 取 1(累加祖先偏移),Fixed2Other 取 -1(抵消祖先偏移) * @param init 偏移量初始值 */ const accumulatePositionOffset = ( path: MNode[], dir: PositionKey, opposite: PositionKey, sign: 1 | -1, init = 0, ): number => { let offset = init; for (const value of path) { if (hasPositionValue(value.style, opposite) || !isNumber(value.style?.[dir] || 0)) { return 0; } offset = offset + sign * Number(value.style?.[dir] || 0); } return offset; }; export const change2Fixed = (node: MNode, path: MNode[]) => { const style = { ...(node.style || {}), }; // 水平方向:以 left 锚定则累加祖先 left,以 right 锚定则累加祖先 right if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) { const left = accumulatePositionOffset(path, 'left', 'right', 1); if (left) { style.left = left; } } else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) { const right = accumulatePositionOffset(path, 'right', 'left', 1); if (right) { style.right = right; } } // 垂直方向:以 top 锚定则累加祖先 top,以 bottom 锚定则累加祖先 bottom if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) { const top = accumulatePositionOffset(path, 'top', 'bottom', 1); if (top) { style.top = top; } } else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) { const bottom = accumulatePositionOffset(path, 'bottom', 'top', 1); if (bottom) { style.bottom = bottom; } } return style; }; export const Fixed2Other = async ( node: MNode, path: MNode[], getLayout: (parent: MNode, node?: MNode) => Promise, ) => { const cur = path.pop(); const offset = { left: 0, top: 0, right: 0, bottom: 0, }; // 水平方向:抵消祖先在对应锚定方向上的偏移量,初始值为节点自身的偏移 if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) { offset.left = accumulatePositionOffset(path, 'left', 'right', -1, Number(cur?.style?.left || 0)); } else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) { offset.right = accumulatePositionOffset(path, 'right', 'left', -1, Number(cur?.style?.right || 0)); } // 垂直方向:同上 if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) { offset.top = accumulatePositionOffset(path, 'top', 'bottom', -1, Number(cur?.style?.top || 0)); } else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) { offset.bottom = accumulatePositionOffset(path, 'bottom', 'top', -1, Number(cur?.style?.bottom || 0)); } const style = node.style || {}; const parent = path.pop(); if (!parent) { return getRelativeStyle(style); } const layout = await getLayout(parent); if (layout !== Layout.RELATIVE) { if (offset.left) { style.left = offset.left; } if (offset.top) { style.top = offset.top; } if (offset.right) { style.right = offset.right; } if (offset.bottom) { style.bottom = offset.bottom; } return { ...style, position: 'absolute', }; } return getRelativeStyle(style); }; export const getGuideLineFromCache = (key: string): number[] => { if (!key) return []; const guideLineCacheData = globalThis.localStorage.getItem(key); if (guideLineCacheData) { try { return JSON.parse(guideLineCacheData) || []; } catch (e) { console.error(e); } } return []; }; export const fixNodeLeft = (config: MNode, parent: MContainer, doc?: Document) => { if (!doc || !config.style || !isNumber(config.style.left)) return config.style?.left; const el = getElById()(doc, `${config.id}`); const parentEl = getElById()(doc, `${parent.id}`); const left = Number(config.style?.left) || 0; if (el && parentEl) { const calcParentOffsetWidth = calcValueByFontsize(doc, parentEl.offsetWidth); const calcElOffsetWidth = calcValueByFontsize(doc, el.offsetWidth); if (calcElOffsetWidth + left > calcParentOffsetWidth) { return calcParentOffsetWidth - calcElOffsetWidth; } } return config.style.left; }; export const fixNodePosition = (config: MNode, parent: MContainer, stage: StageCore | null) => { if (config.style?.position !== 'absolute') { return config.style; } const style = { ...(config.style || {}) }; const baseStyle = config.style || {}; if ('left' in baseStyle && !('right' in baseStyle)) { style.left = fixNodeLeft(config, parent, stage?.renderer?.contentWindow?.document); } if ('top' in baseStyle && !('bottom' in baseStyle)) { style.top = getMiddleTop(config, parent, stage); } return style; }; // 序列化配置 // 仅去掉对象 key 的双引号;字符串值内的 "xxx": 不应被误处理 // serialize-javascript 在 space: 2 时,每个 key 都会出现在换行 + 空白缩进之后, // 因此通过 (^|\n)\s* 锚定行首缩进,避免匹配到字符串值中的 \"xxx\": export const serializeConfig = (config: any) => serialize(config, { space: 2, unsafe: true, }).replace(/(^|\n)(\s*)"(\w+)":\s/g, '$1$2$3: '); export const moveItemsInContainer = (sourceIndices: number[], parent: MContainer, targetIndex: number) => { sourceIndices.sort((a, b) => a - b); for (let i = sourceIndices.length - 1; i >= 0; i--) { const sourceIndex = sourceIndices[i]; if (sourceIndex === targetIndex) { continue; } const [item] = parent.items.splice(sourceIndex, 1); parent.items.splice(sourceIndex < targetIndex ? targetIndex - 1 : targetIndex, 0, item); // 更新后续源索引(因为数组已经改变) for (let j = i - 1; j >= 0; j--) { if (sourceIndices[j] >= targetIndex) { sourceIndices[j] += 1; } } } }; const isIncludeDataSourceByDiffAddResult = (diffResult: any) => { for (const value of Object.values(diffResult)) { const result = isValueIncludeDataSource(value); if (result) { return true; } if (isObject(value)) { return isIncludeDataSourceByDiffAddResult(value); } } return false; }; const isIncludeDataSourceByDiffUpdatedResult = (diffResult: any, oldNode: any) => { for (const [key, value] of Object.entries(diffResult)) { if (isValueIncludeDataSource(value)) { return true; } if (isValueIncludeDataSource(oldNode[key])) { return true; } if (isObject(value)) { return isIncludeDataSourceByDiffUpdatedResult(value, oldNode[key]); } } return false; }; export const isIncludeDataSource = (node: MNode, oldNode: MNode) => { const diffResult = detailedDiff(oldNode, node); let isIncludeDataSource = false; if (diffResult.updated) { // 修改了显示条件 if ((diffResult.updated as any)[NODE_CONDS_KEY]) { return true; } isIncludeDataSource = isIncludeDataSourceByDiffUpdatedResult(diffResult.updated, oldNode); if (isIncludeDataSource) return true; } if (diffResult.added) { isIncludeDataSource = isIncludeDataSourceByDiffAddResult(diffResult.added); if (isIncludeDataSource) return true; } if (diffResult.deleted) { // 删除了显示条件 if ((diffResult.deleted as any)[NODE_CONDS_KEY]) { return true; } isIncludeDataSource = isIncludeDataSourceByDiffAddResult(diffResult.deleted); if (isIncludeDataSource) return true; } return isIncludeDataSource; }; export const buildChangeRecords = (value: any, basePath: string) => { const changeRecords: { propPath: string; value: any }[] = []; // 递归构建 changeRecords const buildChangeRecords = (obj: any, basePath: string) => { Object.entries(obj).forEach(([key, value]) => { if (value !== undefined) { const currentPath = basePath ? `${basePath}.${key}` : key; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { // 递归处理嵌套对象 buildChangeRecords(value, currentPath); } else { // 处理基础类型值 changeRecords.push({ propPath: currentPath, value }); } } }); }; buildChangeRecords(value, basePath); 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, path } = getNodeInfoFn(id); if (!node) throw new Error('获取不到组件信息'); if (node.id === rootId) throw new Error('不能选根节点'); return { node, parent, page, path }; }; /** * 处理节点在 fixed 定位与其他定位之间的切换 * 当节点从非 fixed 变为 fixed 时,根据节点路径累加偏移量;反之则还原偏移量 * @param dist 更新后的节点配置(目标状态) * @param src 更新前的节点配置(原始状态) * @param root 根节点配置,用于计算节点路径上的偏移量 * @param getLayoutFn 获取父节点布局方式的回调函数 * @returns 处理后的节点配置(深拷贝) */ export const toggleFixedPosition = async ( dist: MNode, src: MNode, path: MNode[], 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, path); } else if (!isFixed(newConfig.style) && isFixed(src.style || {})) { newConfig.style = await Fixed2Other(newConfig, path, 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 }; }; /** * 给「回滚」生成的新 step 用的简短描述生成器。 * 与 UI 层 `describePageStep` 同义,但避免 service 反向依赖 layouts/,故放在此工具函数中。 */ export const describeStepForRevert = (step: StepValue): string => { const items = step.diff ?? []; // 在可读名后拼接组件 id,便于在历史面板中精确定位被回滚的组件;id 缺失时退化为仅展示名称。 const withId = (node: MNode | undefined, label: string): string => { const id = node?.id; if (id === undefined || id === null || `${id}` === '') return label; return label ? `${label}(id: ${id})` : `id: ${id}`; }; switch (step.opType) { case 'add': { const count = items.length; const node = items[0]?.newSchema; const label = node?.name || node?.type || ''; return `撤回新增 ${count} 个节点${count === 1 ? `(${withId(node, label)})` : ''}`; } case 'remove': { const count = items.length; const node = items[0]?.oldSchema; const label = node?.name || node?.type || ''; return `还原已删除的 ${count} 个节点${count === 1 ? `(${withId(node, label)})` : ''}`; } case 'update': default: { if (items.length === 1) { const { newSchema, oldSchema, changeRecords } = items[0]; const node = newSchema || oldSchema; const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || ''; const target = withId(node, label); const propPath = changeRecords?.[0]?.propPath; return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`; } return `还原 ${items.length} 个节点的修改`; } } };