UNPKG

vue2-mind-map

Version:

`vue2-mind-map` 是一个功能丰富的思维导图可视化组件。该组件提供了完整的思维导图展示和交互功能,支持多种数据格式输入、灵活的布局配置、丰富的样式定制和流畅的用户交互体验。目前基于 Vue 2 构建。

1,507 lines (1,495 loc) 304 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('vue')) : typeof define === 'function' && define.amd ? define(['exports', 'vue'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.MindMap = {}, global.Vue)); })(this, (function (exports, Vue) { 'use strict'; /** * 表示二维空间中的矢量 */ class Vector { /** * 矢量的 x 坐标 */ x; /** * 矢量的 y 坐标 */ y; /** * 创建一个新的矢量 * @param x - x 坐标值 * @param y - y 坐标值 */ constructor(x, y) { this.x = x; this.y = y; } /** * 将当前矢量与另一个矢量相加 * @param vector - 要添加的矢量 * @returns 相加后的新矢量 */ add(vector) { return new Vector(this.x + vector.x, this.y + vector.y); } /** * 将当前矢量乘以标量 * @param scalar - 乘数 * @returns 缩放后的新矢量 */ multiply(scalar) { return new Vector(this.x * scalar, this.y * scalar); } /** * 判断当前矢量是否与另一个矢量相等 * @param vector - 要比较的矢量 * @returns 如果两个矢量的 x 和 y 坐标相等,则返回 true,否则返回 false */ equals(vector) { return this.x === vector.x && this.y === vector.y; } /** * 创建当前矢量的副本 * @returns 当前矢量的一个新副本 */ clone() { return new Vector(this.x, this.y); } } /** * 生成uuid/guid * @see https://stackoverflow.com/questions/105034/how-to-create-guid-uuid * @returns {string} uuid 字符串 */ function uuidv4() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0, v = c == "x" ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * 生成随机字符串 * @param format 字符串格式(*为占位符) * @returns */ function getRamdomStr(format = '********') { return format.replace(/[*]/g, function (c) { const r = Math.random() * 16 | 0, v = c == "*" ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * 递归遍历树节点,并对每个节点执行回调函数 (前序遍历) * * @remarks * 前序遍历(pre-order)的顺序是:父节点 -> 子节点。 * 例如,对于以下树结构: * ``` * A * / \ * B C * / \ \ *D E F * ``` * 遍历顺序将是:A -> B -> D -> E -> C -> F。 * * @template T 节点类型,必须包含 children 属性 * @param node 当前遍历到的节点 * @param callback 对每个节点执行的回调函数,接收当前节点和其父节点作为参数 * @param parentNode 当前节点的父节点 (在递归调用中传递) */ function traverseTreeRecursive(node, callback, parentNode = null) { // 基本的递归终止条件和参数校验 if (!node || !callback) return; // 对当前节点执行回调函数 callback(node, parentNode); // 如果当前节点有子节点,则递归遍历子节点 if (node.children?.length) { for (const child of node.children) { // 递归调用,将当前节点作为父节点传递下去 traverseTreeRecursive(child, callback, node); } } } /** * 增强版递归遍历树节点 (深度优先),支持前序和后序遍历、深度限制和条件中断 * * @remarks * 深度优先遍历包括前序和后序两种模式: * - **前序遍历 (pre-order)**: 父 -> 子。处理顺序: A -> B -> D -> E -> C -> F。适合自上而下的处理 * - **后序遍历 (post-order)**: 子 -> 父。处理顺序: D -> E -> B -> F -> C -> A。适合自下而上的处理(如删除操作) * * 对于以下树结构: * ``` * A * / \ * B C * / \ \ *D E F * ``` * * @template T 节点类型,必须包含 children 属性 * @param node 当前遍历到的节点 * @param callback 对每个节点执行的回调函数。在前序遍历中,若返回 `false`,则会停止对当前分支的进一步遍历 * @param options 遍历选项,用于控制遍历行为 * @param parentNode 当前节点的父节点 (在递归调用中传递) * @param currentDepth 当前遍历深度 (在递归调用中传递) * * @example <caption>深度限制</caption> * // 只遍历到深度为 1 的节点 (根节点深度为 0) * traverseTreeRecursiveAdvanced(root, (node, parent, depth) => { * console.log(`层级 ${depth}: ${node.name}`); * }, { maxDepth: 1 }); * // 输出: * // 层级 0: 根节点 * // 层级 1: 子节点1 * // 层级 1: 子节点2 * * @example <caption>条件停止遍历 (仅前序)</caption> * // 当遇到 isHidden 为 true 的节点时,停止遍历其所在的分支 * traverseTreeRecursiveAdvanced(root, (node) => { * if (node.isHidden) { * return false; // 停止遍历这个分支 * } * console.log(node.name); * }, { order: 'pre', stopOnFalse: true }); */ function traverseTreeRecursiveAdvanced(node, callback, options = {}, parentNode = null, currentDepth = 0) { // 参数校验 if (!node || !callback) return; const { order = 'pre', maxDepth = -1, stopOnFalse = false } = options; // 检查深度限制 if (maxDepth >= 0 && currentDepth > maxDepth) return; // 前序遍历:先处理当前节点 if (order === 'pre') { const result = callback(node, parentNode, currentDepth); // 如果回调返回 false,则停止遍历该分支 if (stopOnFalse && result === false) { return; } } // 遍历子节点 if (node.children?.length) { for (const child of node.children) { traverseTreeRecursiveAdvanced(child, callback, options, node, currentDepth + 1); } } // 后序遍历:后处理当前节点 if (order === 'post') { // 注意:在后序遍历中,回调的返回值不会影响遍历流程 callback(node, parentNode, currentDepth); } } /** * 在树中查找第一个满足条件的节点 (深度优先) * @template T 节点类型 * @param root 根节点 * @param predicate 查找条件,返回 true 表示找到 * @returns 找到的节点或 null */ function findNodeInTree(root, predicate) { if (!root || !predicate) return null; // 检查当前节点 if (predicate(root)) { return root; } // 递归查找子节点 if (root.children?.length) { for (const child of root.children) { const found = findNodeInTree(child, predicate); // 如果在子树中找到了,立即返回,中断后续所有遍历 if (found) { return found; } } } // 遍历完所有子节点都未找到 return null; } const DEFAULT_SIZE = new Vector(100, 100); class MindNode { id; // 节点ID parent; // 父节点 children = []; // 子节点集合 position; // 位置 size; // 尺寸 collapsed; // 节点是否折叠 totalChildrenSize; // 子节点总尺寸(用于布局计算) totalChildrenCount; // 子节点总数量 data = null; // 存储节点自定义数据 _content; // 节点内容 rgTf = { depth: 0, prelim: 0, mod: 0, shift: 0, change: 0, thread: undefined, ancestor: undefined, lmost_sibling: undefined, number: 0 // 在同级节点中的索引 (0-based) }; slDv = { maxSubtreeSpan: 0, isSizeLargerThanSubtreeSpan: false, excessSizeOverSubtreeSpan: 0, // 本身高度大于下级的占位的高度差 }; get width() { return this.size.x; } set width(value) { this.size.x = value; } get height() { return this.size.y; } set height(value) { this.size.y = value; } get x() { return this.position.x; } set x(value) { this.position.x = value; } get y() { return this.position.y; } set y(value) { this.position.y = value; } get content() { return this._content; } set content(value) { this._content = value; } /** * 创建一个思维导图节点实例 * * @param options - 节点配置选项 * @param options.id - 节点唯一标识,默认自动生成UUID * @param options.position - 节点位置向量,默认为(0,0) * @param options.size - 节点尺寸向量,默认为(100,100) * @param options.collapsed - 是否折叠节点,默认为false * @param children - 子节点列表,默认为空数组 */ constructor(options = {}, children = []) { const { id = uuidv4(), position = new Vector(0, 0), size = DEFAULT_SIZE.clone(), collapsed = false // 默认为不折叠 } = options; this.id = id; this.position = position; this.size = size; this.collapsed = collapsed; this.children = children; this.children.forEach(child => { child.parent = this; }); } // 添加子节点 addChild(node) { // 防止添加自身 if (node === this) { throw new Error("Cannot add self as child"); } // 防止添加祖先节点(避免循环引用) let parent = this.parent; while (parent) { if (parent === node) { throw new Error("Cannot add ancestor as child"); } parent = parent.parent; } if (node.parent) { node.parent.removeChild(node); } this.children.push(node); node.parent = this; } // 移除子节点 removeChild(node) { const index = this.children.indexOf(node); if (index !== -1) { this.children.splice(index, 1); node.parent = undefined; } } /** * 深度优先遍历节点树 * @param callback - 对每个遍历到的节点执行的回调函数 * @param {boolean} [skipCollapsedSubtrees=false] - 可选参数,如果为 true,则当遇到折叠的节点时,不会遍历其子树。默认为 false,即遍历所有节点。 */ traverse(callback, skipCollapsedSubtrees = false) { traverseTreeRecursiveAdvanced(this, (node) => { callback(node); // 如果需要跳过折叠的子树,并且当前节点是折叠的,则返回 false 来终止该分支的遍历 if (skipCollapsedSubtrees && node.collapsed) { return false; } }, { order: 'pre', // 仅当 skipCollapsedSubtrees 为 true 时才启用 stopOnFalse stopOnFalse: skipCollapsedSubtrees, }); } // 查找方法 findById(id) { return findNodeInTree(this, (node) => node.id === id); } // 移动整个子树 moveTo(newParent) { if (this.parent) { this.parent.removeChild(this); } newParent.addChild(this); } } // Generated by Claude 3.7 Sonnet Thinking /** * 渲染管理器 - 用于统一管理渲染流程和优化 */ class RenderManager { // 单例实例 static instance; /** * 渲染队列 - 存储需要渲染的节点 * @remark * 使用 Set 来存储节点,保证每个节点在队列中只存在一次,避免重复渲染 */ renderQueue = new Set(); // 动画帧 ID - 用于 requestAnimationFrame animationFrameId; // 是否正在渲染 - 用于避免重复渲染 isRendering = false; /** * 获取单例实例 * @returns RenderManager 实例 */ static getInstance() { if (!RenderManager.instance) { RenderManager.instance = new RenderManager(); } return RenderManager.instance; } constructor() { // 私有构造函数,防止外部直接创建实例 } /** * 将节点添加到渲染队列 * @param node 需要渲染的节点 */ queueForRender(node) { this.renderQueue.add(node); this.scheduleRender(); } /** * 批量将节点添加到渲染队列 * @param nodes 需要渲染的节点数组 */ queueMultiple(nodes) { nodes.forEach(node => this.renderQueue.add(node)); this.scheduleRender(); } /** * 从渲染队列中移除节点 * @param node 要移除的节点 */ unqueueFromRender(node) { this.renderQueue.delete(node); } /** * 安排渲染(使用requestAnimationFrame进行优化) */ scheduleRender() { if (!this.animationFrameId && !this.isRendering) { this.animationFrameId = requestAnimationFrame(() => this.render()); } } /** * 执行实际渲染 */ render() { this.animationFrameId = undefined; this.isRendering = true; // 按照层级排序节点,确保父节点先于子节点渲染(level越小越先渲染) const sortedNodes = Array.from(this.renderQueue).sort((a, b) => a.level - b.level); // 执行渲染 sortedNodes.forEach(node => { if (this.renderQueue.has(node)) { node.render(); this.renderQueue.delete(node); } }); this.isRendering = false; // 在渲染过程中,如果有新的节点加入队列,则重新安排渲染 if (this.renderQueue.size > 0) { this.scheduleRender(); } } /** * 立即渲染指定节点(不经过队列) * @param node 要立即渲染的节点 */ renderImmediate(node) { node.render(); } /** * 立即渲染所有队列中的节点 */ flushRender() { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; } this.render(); } /** * 清空渲染队列 */ clearQueue() { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; } this.renderQueue.clear(); } /** * 管理容器 - 将容器添加到渲染队列 * @param container 要管理的容器 */ manageContainer(container) { // 将容器添加到渲染队列 this.queueForRender(container); } /** * 批量渲染容器内的所有节点 * @param container 容器 */ renderContainer(container) { const allNodes = this.collectAllNodes(container); this.queueMultiple(allNodes); this.flushRender(); } /** * 收集容器中的所有节点(包括嵌套容器中的节点) * @param container 容器 * @returns 所有节点数组 */ collectAllNodes(container) { const result = []; // 使用 TreeUtils 中的 traverseTreeRecursive 进行前序遍历来收集所有节点 // 由于节点树是异构的 (Container 有 children,而普通 Node 没有), // 但 traverseTreeRecursive 的泛型约束 T extends { children?: T[] } 期望一个同构树 // 因此我们这里使用 `any` 类型来灵活处理这种异构性。 // 在运行时,`traverseTreeRecursive` 内部的 `node.children?.length` 可以正确处理没有 children 属性的节点 traverseTreeRecursive(container, (node) => { result.push(node); }); return result; } } /** * 事件发射器基类 * 提供事件注册、移除和触发的基本功能。 * 通过泛型 M (事件映射表) 来实现事件名称和事件数据的类型安全。 * @template M 一个映射表,其键是事件名称 (字符串),其值是该事件对应的数据类型。 * 默认为 BaseEventMap,允许任何字符串事件名和 any 类型的数据。 */ class EventEmitter { // 事件监听器映射。 // 内部存储使用 string 作为键,值为对应事件处理函数数组。 // 类型安全通过公共方法签名来保证。 _eventListeners = new Map(); /** * 添加事件监听器。 * @param eventName 事件类型,必须是 M 中的一个键。 * @param handler 事件处理函数,它接收特定于此事件类型的数据 (M[K])。 */ on(eventName, handler) { const eventKey = eventName; // K 是 M 的键,M 继承自 Record<string, any>,所以 K 是字符串兼容的 if (!this._eventListeners.has(eventKey)) { this._eventListeners.set(eventKey, []); } // 将具体类型的 handler (EventHandler<M[K]>) 存入 EventHandler<any> 数组是安全的(向上转型) this._eventListeners.get(eventKey).push(handler); return this; } /** * 添加只执行一次的事件监听器。 * @param eventName 事件类型,必须是 M 中的一个键。 * @param handler 事件处理函数,接收 M[K] 类型的数据。 */ once(eventName, handler) { // 包装后的 onceHandler 保持了与原始 handler 相同的类型签名 EventHandler<M[K]> const onceHandler = (eventData) => { this.off(eventName, onceHandler); // 移除包装后的 handler handler(eventData); }; // 将原始处理函数附加到包装器上,以便 off 方法能够通过它来移除监听器 onceHandler._originalHandler = handler; this.on(eventName, onceHandler); // on 方法会正确处理类型 return this; } /** * 移除事件监听器。 * @param eventName 事件类型,必须是 M 中的一个键。 * @param handler 事件处理函数。 */ off(eventName, handler) { const eventKey = eventName; if (!this._eventListeners.has(eventKey)) return this; const handlers = this._eventListeners.get(eventKey); // 需要查找原始处理函数(如果是通过 once 添加的)或处理函数本身 const index = handlers.findIndex(h => h === handler || h._originalHandler === handler); if (index !== -1) { handlers.splice(index, 1); } // 如果移除后没有处理函数了,删除该事件类型 if (handlers.length === 0) { this._eventListeners.delete(eventKey); } return this; } /** * 触发事件。 * @param eventName 事件类型,必须是 M 中的一个键。 * @param eventData 事件数据,其类型应为 M[K]。 */ emit(eventName, eventData) { const eventKey = eventName; if (!this._eventListeners.has(eventKey)) return; // 复制一份处理函数数组,防止在回调中修改监听器列表导致问题 // 从 _eventListeners 中获取的 handlers 数组元素类型是 EventHandler<any> // 但我们知道对于特定的 eventName K,它们实际上是 EventHandler<M[K]> (由 on 方法保证) // 因此,使用 M[K] 类型的 eventData 调用它们是类型安全的 const handlersToExecute = [...this._eventListeners.get(eventKey)]; handlersToExecute.forEach(handler => handler(eventData)); } /** * 移除指定类型的所有事件监听器。 * @param eventName 事件类型 (M 中的键),如果不提供则移除所有类型的监听器。 */ removeAllListeners(eventName) { if (eventName) { const eventKey = eventName; this._eventListeners.delete(eventKey); } else { this._eventListeners.clear(); } return this; } } /** * 表示一个3x3的变换矩阵,用于处理2D空间中 * 的平移、旋转、缩放等仿射变换。 */ class Matrix { /** * 存储3x3矩阵数据的二维数组 * 索引结构: [row][column] */ data; /** * 创建一个新的矩阵对象 * 默认初始化为单位矩阵 */ constructor() { this.data = [ [1, 0, 0], [0, 1, 0], [0, 0, 1] ]; } /** * 创建一个新的单位矩阵 * @returns 新的单位矩阵实例 */ static identity() { return new Matrix(); } /** * 创建一个平移变换矩阵 * @param x - X轴平移距离 * @param y - Y轴平移距离 * @returns 新的平移变换矩阵 */ static translation(x, y) { const m = new Matrix(); m.data[0][2] = x; m.data[1][2] = y; return m; } /** * 创建一个旋转变换矩阵 * @param angle - 旋转角度(弧度制) * @returns 新的旋转变换矩阵 */ static rotation(angle) { const cos = Math.cos(angle); const sin = Math.sin(angle); const m = new Matrix(); m.data[0][0] = cos; m.data[0][1] = -sin; m.data[1][0] = sin; m.data[1][1] = cos; return m; } /** * 创建一个缩放变换矩阵 * @param sx - X轴方向的缩放因子 * @param sy - Y轴方向的缩放因子 * @returns 新的缩放变换矩阵 */ static scaling(sx, sy) { const m = new Matrix(); m.data[0][0] = sx; m.data[1][1] = sy; return m; } /** * 将当前矩阵与另一个矩阵相乘 * 计算 this * other 的结果 * @param other - 要相乘的另一个矩阵 * @returns 矩阵乘法的结果矩阵 */ multiply(other) { const result = new Matrix(); for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { result.data[i][j] = 0; for (let k = 0; k < 3; k++) { result.data[i][j] += this.data[i][k] * other.data[k][j]; } } } return result; } /** * 创建当前矩阵的深拷贝 * @returns 当前矩阵的副本 */ clone() { const result = new Matrix(); for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { result.data[i][j] = this.data[i][j]; } } return result; } /** * 使用当前矩阵变换一个点 * @param x - 点的 x 坐标 * @param y - 点的 y 坐标 * @returns 变换后的点坐标 */ transformPoint(x, y) { const newX = this.data[0][0] * x + this.data[0][1] * y + this.data[0][2]; const newY = this.data[1][0] * x + this.data[1][1] * y + this.data[1][2]; return { x: newX, y: newY }; } } class Style { // 字体属性 fontFamily; fontSize; fontStyle; fontWeight; // 颜色属性 color; // 边框属性 borderWidth; borderColor; borderStyle; // 背景属性 backgroundColor; } /** * 节点基类 */ class Node extends EventEmitter { /** 节点 ID */ id; // 用于生成唯一节点 ID 的静态计数器 static _nextNodeId = 0; // 父节点引用 _parent; // 添加层级属性,默认为0(根节点) _level = 0; // 样式 _style = new Style(); // 局部变换 _localMatrix = new Matrix(); // 全局变换(缓存子节点的全局变换矩阵) _globalMatrix = new Matrix(); // 当前缩放级别 _zoomLevel = 0; // 基础缩放因子 (例如 1.1 表示每次缩放10%) _zoomFactorBase; /** * Node 构造函数 * @param options 可选的构造参数对象 */ constructor(options) { super(); // EventEmitter constructor const { id, zoomFactorBase = 1.1 } = options || {}; if (id) { this.id = id; } else { // 使用当前类的名称(小写)作为前缀 this.id = `${this.constructor.name.toLowerCase()}-${Node._nextNodeId++}`; } this._zoomFactorBase = zoomFactorBase; } /** 获取父节点 */ get parent() { return this._parent; } /** 设置父节点 */ set parent(value) { this._parent = value; this.updateLevel(); // 更新层级 } /** 获取节点层级 */ get level() { return this._level; } /** 获取节点样式 */ get style() { return this._style; } /** 获取局部变换矩阵的克隆副本 */ get localMatrix() { return this._localMatrix.clone(); } /** 获取全局变换矩阵的克隆副本 */ get globalMatrix() { return this._globalMatrix.clone(); } /** 节点的局部坐标 */ get position() { return new Vector(this._localMatrix.data[0][2], this._localMatrix.data[1][2]); } /** 节点的局部缩放比例 */ get scaling() { return new Vector(this._localMatrix.data[0][0], this._localMatrix.data[1][1]); } /** 节点的局部旋转角度 */ get rotation() { return Math.atan2(this._localMatrix.data[1][0], this._localMatrix.data[0][0]); } /** 节点的全局坐标 */ get globalPosition() { return new Vector(this._globalMatrix.data[0][2], this._globalMatrix.data[1][2]); } /** 节点的全局缩放比例 */ get globalScaling() { return new Vector(this._globalMatrix.data[0][0], this._globalMatrix.data[1][1]); } /** 节点的全局旋转角度 */ get globalRotation() { return Math.atan2(this._globalMatrix.data[1][0], this._globalMatrix.data[0][0]); } /** 获取当前缩放级别 */ get zoomLevel() { return this._zoomLevel; } /** 获取基础缩放因子 */ get zoomFactorBase() { return this._zoomFactorBase; } /** * 更新当前节点的层级 * @remarks * 根据父节点层级计算自身层级,如无父节点则为0级(根节点) */ updateLevel() { this._level = this._parent ? this._parent.level + 1 : 0; } // ------------------ 实现 IStyleable 接口 ------------------ /** * 应用字体样式 * @param fontStyle - 要应用的字体样式对象 */ applyFont(fontStyle) { this.style.fontFamily = fontStyle.fontFamily; this.style.fontSize = fontStyle.fontSize; this.style.fontStyle = fontStyle.fontStyle; this.style.fontWeight = fontStyle.fontWeight; } /** * 应用颜色样式 * @param colorStyle - 要应用的颜色样式对象 */ applyColor(colorStyle) { this.style.color = colorStyle.color; } /** * 应用边框样式 * @param borderStyle - 要应用的边框样式对象 */ applyBorder(borderStyle) { this.style.borderWidth = borderStyle.borderWidth; this.style.borderColor = borderStyle.borderColor; this.style.borderStyle = borderStyle.borderStyle; } /** * 应用背景样式 * @param backgroundStyle - 要应用的背景样式对象 */ applyBackground(backgroundStyle) { this.style.backgroundColor = backgroundStyle.backgroundColor; } // ------------------ 实现 ITransformable 接口 ------------------ /** * 更新全局变换矩阵 * * @remarks * 如果存在父节点,全局变换矩阵是父节点全局变换与自身局部变换的组合 * 如果是根节点,全局变换矩阵等同于局部变换矩阵 */ updateGlobalTransform() { if (this._parent) { // 如果有父节点,全局变换 = 父节点全局变换 × 自身局部变换 this._globalMatrix = this._parent._globalMatrix.multiply(this._localMatrix); } else { // 如果是根节点,全局变换等于局部变换 this._globalMatrix = this._localMatrix.clone(); } } /** * 将节点标记为需要渲染,并加入渲染队列 */ _queueRender() { RenderManager.getInstance().queueForRender(this); } /** * 按指定的 x 和 y 距离移动节点 * @param x - 节点沿 x 轴移动的距离 * @param y - 节点沿 Y 轴移动的距离 * @returns 用于方法链的节点实例 */ move(x, y) { const translation = Matrix.translation(x, y); this._localMatrix = translation.multiply(this._localMatrix); this.updateGlobalTransform(); this._queueRender(); return this; } /** * 按指定角度旋转节点 * @param angle - 以弧度为单位的旋转角度 * @param cx - 旋转中心的 x 坐标(默认值:0) * @param cy - 旋转中心的 Y 坐标(默认值:0) * @returns 实例本身,允许方法链 * * @remarks * 如果 cx 和 cy 均为 0,则旋转围绕原点进行。 * 否则,将围绕指定点 (cx, cy) 旋转。 * 旋转后,全局变换矩阵会自动更新。 */ rotate(angle, cx = 0, cy = 0) { if (cx === 0 && cy === 0) { // 围绕原点旋转 const rotation = Matrix.rotation(angle); this._localMatrix = rotation.multiply(this._localMatrix); } else { // 围绕指定点 (cx, cy) 旋转 const translateToOrigin = Matrix.translation(-cx, -cy); const rotation = Matrix.rotation(angle); const translateBack = Matrix.translation(cx, cy); // 组合矩阵:translateBack × rotation × translateToOrigin const combined = translateBack.multiply(rotation).multiply(translateToOrigin); this._localMatrix = combined.multiply(this._localMatrix); } this.updateGlobalTransform(); this._queueRender(); return this; } /** * 沿 X 轴和 Y 轴以指定系数缩放节点 * @param sx - 沿 X 轴的缩放因子 * @param sy - 沿 Y 轴的缩放因子 * @param cx - 缩放中心的 x 坐标(默认值:0) * @param cy - 缩放中心的 Y 坐标(默认值:0) * @returns 用于方法链的节点实例 * * @example * // 围绕原点水平缩放 2 倍 * block.scale(2, 1); * * @example * // 在点(100,100)周围的两个维度上均缩放 0.5 倍 * block.scale(0.5, 0.5, 100, 100); */ scale(sx, sy, cx = 0, cy = 0) { if (cx === 0 && cy === 0) { // 围绕原点缩放 const scaling = Matrix.scaling(sx, sy); this._localMatrix = scaling.multiply(this._localMatrix); } else { // 围绕指定点 (cx, cy) 缩放 const translateToOrigin = Matrix.translation(-cx, -cy); const scaling = Matrix.scaling(sx, sy); const translateBack = Matrix.translation(cx, cy); // 组合矩阵:translateBack × scaling × translateToOrigin const combined = translateBack.multiply(scaling).multiply(translateToOrigin); this._localMatrix = combined.multiply(this._localMatrix); } this.updateGlobalTransform(); this._queueRender(); return this; } /** * 移动节点到指定的局部变换坐标 * @param x - 将节点移动到的目标 x 坐标 * @param y - 将节点移动到的目标 Y 坐标 * @returns 用于方法链的节点实例 */ moveTo(x, y) { const { x: currentX, y: currentY } = this.position; this.move(x - currentX, y - currentY); return this; } /** * 将节点旋转到指定的局部变换角度 * @param angle - 目标旋转角度(以弧度为单位) * @param cx - 旋转中心的 x 坐标(默认值:0) * @param cy - 旋转中心的 Y 坐标(默认值:0) * @returns 用于方法链的节点实例 */ rotateTo(angle, cx = 0, cy = 0) { const currentRotation = this.rotation; this.rotate(angle - currentRotation, cx, cy); return this; } /** * 将节点缩放到指定的局部变换比例 * @param sx - 目标 X 轴缩放比例 * @param sy - 目标 Y 轴缩放比例 * @param cx - 缩放中心的 x 坐标(默认值:0) * @param cy - 缩放中心的 Y 坐标(默认值:0) * @returns 用于方法链的节点实例 */ scaleTo(sx, sy, cx = 0, cy = 0) { const { x: currentScaleX, y: currentScaleY } = this.scaling; this.scale(sx / currentScaleX, sy / currentScaleY, cx, cy); return this; } /** * 将节点移动到指定的全局变换坐标 * @param x - 目标全局 x 坐标 * @param y - 目标全局 Y 坐标 * @returns 用于方法链的节点实例 */ moveToGlobal(x, y) { const { x: currentX, y: currentY } = this.globalPosition; this.move(x - currentX, y - currentY); return this; } /** * 将节点旋转到指定的全局变换角度 * @param angle - 目标全局旋转角度(以弧度为单位) * @param cx - 旋转中心的 x 坐标(默认值:0) * @param cy - 旋转中心的 Y 坐标(默认值:0) * @returns 用于方法链的节点实例 */ rotateToGlobal(angle, cx = 0, cy = 0) { const currentRotation = this.globalRotation; this.rotate(angle - currentRotation, cx, cy); return this; } /** * 将节点缩放到指定的全局变换比例 * @param sx - 目标全局 X 轴缩放比例 * @param sy - 目标全局 Y 轴缩放比例 * @param cx - 缩放中心的 x 坐标(默认值:0) * @param cy - 缩放中心的 Y 坐标(默认值:0) * @returns 用于方法链的节点实例 */ scaleToGlobal(sx, sy, cx = 0, cy = 0) { const { x: currentScaleX, y: currentScaleY } = this.globalScaling; this.scale(sx / currentScaleX, sy / currentScaleY, cx, cy); return this; } /** * 设置基础缩放因子 * @param factor 新的基础缩放因子 * @returns 当前节点实例,用于链式调用 */ setZoomFactorBase(factor) { this._zoomFactorBase = factor; this._applyCurrentZoom(); // 可选:立即应用新的因子到当前级别 return this; } /** * 放大节点 * @param cx - 缩放中心的 x 坐标(默认值:0) * @param cy - 缩放中心的 y 坐标(默认值:0) * @returns 当前节点实例,用于链式调用 */ zoomIn(cx = 0, cy = 0) { this._zoomLevel++; this._applyCurrentZoom(cx, cy); return this; } /** * 缩小节点 * @param cx - 缩放中心的 x 坐标(默认值:0) * @param cy - 缩放中心的 y 坐标(默认值:0) * @returns 当前节点实例,用于链式调用 */ zoomOut(cx = 0, cy = 0) { this._zoomLevel--; this._applyCurrentZoom(cx, cy); return this; } /** * 重置节点的缩放级别到初始状态 (级别0,缩放1) * @param cx - 缩放中心的 x 坐标(默认值:0) * @param cy - 缩放中心的 y 坐标(默认值:0) * @returns 当前节点实例,用于链式调用 */ resetZoom(cx = 0, cy = 0) { this._zoomLevel = 0; this._applyCurrentZoom(cx, cy); return this; } /** * 根据当前的缩放级别应用缩放 * @param cx - 缩放中心的 x 坐标(默认值:0) * @param cy - 缩放中心的 y 坐标(默认值:0) * @remarks * 此方法会调用 scaleTo 来设置节点的缩放,确保精确性 */ _applyCurrentZoom(cx = 0, cy = 0) { const targetScale = Math.pow(this._zoomFactorBase, this._zoomLevel); // 调用自身的 scaleTo 方法,该方法应处理局部缩放 this.scaleTo(targetScale, targetScale, cx, cy); } } /** * 抽象组类,表示可以包含其他节点的容器 */ class Group extends Node { /** 容器内包含的节点 */ _children = []; // 是否允许溢出/是否隐藏溢出内容 _overflowable = false; /** * Node 构造函数 * @param options 可选的构造参数对象 */ constructor(options) { const { id, zoomFactorBase } = options || {}; super({ id, zoomFactorBase }); } /** 获取 是否允许溢出/是否隐藏溢出内容 */ get overflowable() { return this._overflowable; } /** 设置 是否允许溢出/是否隐藏溢出内容 */ set overflowable(value) { this._overflowable = value; } /** 设置父节点,同时更新所有子节点的层级 */ set parent(value) { super.parent = value; // 调用基类的 setter,更新自身层级 this.updateChildrenLevels(); // 更新所有子节点层级 } /** 获取父节点 */ get parent() { return super.parent; } /** * 获取当前组的子节点列表 * @returns 子节点数组 */ get children() { return this._children; } /** * 检查当前组是否包含指定的子节点 * @param node 要检查的节点 * @returns 如果节点是当前组的直接子节点,则返回 true,否则返回 false */ contains(node) { return this._children.includes(node); } /** * 添加子节点 * @param child 要添加的节点 * @returns 当前实例,用于链式调用 */ addChild(child) { this._children.push(child); child.parent = this; // 设置父节点引用,这会触发 updateLevel child.updateGlobalTransform(); // 更新子节点的全局变换 return this; } /** * 移除子节点 * @param child 要移除的节点 * @returns 当前实例,用于链式调用 */ removeChild(child) { const index = this._children.indexOf(child); if (index >= 0) { this._children.splice(index, 1); child.parent = undefined; // 移除父节点引用 } return this; } /** * 计算组内所有子节点的包围盒 * @returns 包含所有子节点的最小包围盒,如果没有子节点则返回一个原点处的零尺寸包围盒 */ getBoundingBox() { if (this._children.length === 0) { // 对于空 Group,返回一个在原点,宽高为0的包围盒 // 或者可以根据 Group 自身是否有尺寸来决定,但目前 Group 没有直接的宽高属性 const ownGlobalPos = this.globalPosition; return { width: 0, height: 0, x: ownGlobalPos.x, y: ownGlobalPos.y, left: ownGlobalPos.x, top: ownGlobalPos.y, right: ownGlobalPos.x, bottom: ownGlobalPos.y }; } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; // 遍历所有子节点,计算全局坐标系下的边界 for (const child of this._children) { // 定义四个角点(子节点的局部坐标系) const localCorners = [ { x: 0, y: 0 }, { x: child.width, y: 0 }, { x: 0, y: child.height }, { x: child.width, y: child.height } // 右下角 ]; // 将角点转换为全局坐标系 for (const localCorner of localCorners) { // 应用子节点的全局变换矩阵 const globalPoint = child.globalMatrix.transformPoint(localCorner.x, localCorner.y); // 更新包围盒边界 minX = Math.min(minX, globalPoint.x); minY = Math.min(minY, globalPoint.y); maxX = Math.max(maxX, globalPoint.x); maxY = Math.max(maxY, globalPoint.y); } // 如果子节点是 Group,递归获取其包围盒并合并 // 注意:如果一个 Group 本身没有子节点,其 getBoundingBox 会返回其自身位置的零尺寸包围盒 // 这意味着不需要特别处理 childBoundingBox 为 null 的情况,因为它总会返回一个 IBoundingBox if (child instanceof Group) { const childBoundingBox = child.getBoundingBox(); // 只有当子组的包围盒有效(即宽度或高度不为0,或者它不是初始的Infinity值)时才合并 // 或者更简单地,总是合并,因为Infinity会被任何实际数值覆盖 minX = Math.min(minX, childBoundingBox.left); minY = Math.min(minY, childBoundingBox.top); maxX = Math.max(maxX, childBoundingBox.right); maxY = Math.max(maxY, childBoundingBox.bottom); } } // 如果所有子节点都是零尺寸且位于同一位置,或者没有有效子节点进行计算(例如,初始值未被覆盖) // 此时 minX, minY 可能仍然是 Infinity, maxX, maxY 是 -Infinity // 这种情况下,返回一个基于组自身位置的零尺寸包围盒 if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { const ownGlobalPos = this.globalPosition; return { width: 0, height: 0, x: ownGlobalPos.x, y: ownGlobalPos.y, left: ownGlobalPos.x, top: ownGlobalPos.y, right: ownGlobalPos.x, bottom: ownGlobalPos.y }; } // 创建并返回包围盒 const boundingBox = { x: minX, y: minY, width: maxX - minX, height: maxY - minY, left: minX, top: minY, right: maxX, bottom: maxY }; return boundingBox; } /** * 计算组内所有子节点在局部坐标系中的包围盒 * @returns 包含所有子节点的最小包围盒,如果没有子节点则返回一个原点处的零尺寸包围盒 */ getLocalBoundingBox() { if (this._children.length === 0) { // 对于空 Group,返回一个在原点,宽高为0的包围盒 return { width: 0, height: 0, x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0 }; } let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; // 遍历所有子节点,计算局部坐标系下的边界 for (const child of this._children) { // 定义四个角点(子节点的局部坐标系) const localCorners = [ { x: 0, y: 0 }, { x: child.width, y: 0 }, { x: 0, y: child.height }, { x: child.width, y: child.height } // 右下角 ]; // 将角点转换为当前 Group 的局部坐标系 for (const corner of localCorners) { // 应用子节点的局部变换矩阵 const transformedPoint = child.localMatrix.transformPoint(corner.x, corner.y); // 更新包围盒边界 minX = Math.min(minX, transformedPoint.x); minY = Math.min(minY, transformedPoint.y); maxX = Math.max(maxX, transformedPoint.x); maxY = Math.max(maxY, transformedPoint.y); } // 如果子节点是 Group,递归获取其局部包围盒并合并 if (child instanceof Group) { const childLocalBoundingBox = child.getLocalBoundingBox(); // 将子 Group 的局部包围盒转换到当前 Group 的局部坐标系 const corners = [ { x: childLocalBoundingBox.left, y: childLocalBoundingBox.top }, { x: childLocalBoundingBox.right, y: childLocalBoundingBox.top }, { x: childLocalBoundingBox.left, y: childLocalBoundingBox.bottom }, { x: childLocalBoundingBox.right, y: childLocalBoundingBox.bottom } // 右下角 ]; for (const corner of corners) { // 应用子节点的局部变换矩阵 const transformedPoint = child.localMatrix.transformPoint(corner.x, corner.y); // 更新包围盒边界 minX = Math.min(minX, transformedPoint.x); minY = Math.min(minY, transformedPoint.y); maxX = Math.max(maxX, transformedPoint.x); maxY = Math.max(maxY, transformedPoint.y); } } } // 处理边界情况:如果没有有效子节点或所有子节点都是零尺寸 if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { return { width: 0, height: 0, x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0 }; } // 创建并返回包围盒 const boundingBox = { x: minX, y: minY, width: maxX - minX, height: maxY - minY, left: minX, top: minY, right: maxX, bottom: maxY }; return boundingBox; } /** 更新自身及所有子元素的全局变换 */ updateGlobalTransform() { super.updateGlobalTransform(); // 更新自身全局变换 // 更新所有子元素的全局变换 for (const child of this._children) { child.updateGlobalTransform(); } } /** * 应用字体样式 * @param fontStyle - 要应用的字体样式对象 */ applyFont(fontStyle) { super.applyFont(fontStyle); this.target.applyFont(fontStyle); } /** * 应用颜色样式 * @param colorStyle - 要应用的颜色样式对象 */ applyColor(colorStyle) { super.applyColor(colorStyle); this.target.applyColor(colorStyle); } /** * 应用边框样式 * @param borderStyle - 要应用的边框样式对象 */ applyBorder(borderStyle) { super.applyBorder(borderStyle); this.target.applyBorder(borderStyle); } /** * 应用背景样式 * @param backgroundStyle - 要应用的背景样式对象 */ applyBackground(backgroundColor) { super.applyBackground(backgroundColor); this.target.applyBackground(backgroundColor); } // 更新所有子节点层级的方法(递归) updateChildrenLevels() { for (const child of this._children) { child.updateLevel(); // 如果子节点是 Group,递归更新其子节点 if (child instanceof Group) { child.updateChildrenLevels(); } } } } /** * 定义了一个 DOM 元素的包装器类,提供了一些方法来操作和管理 DOM 元素的样式、位置、旋转、缩放等属性 * 其中关于 DOM 元素的定位,是基于父元素是已定位(非 static),子元素是绝对定位的原则 * 在 CSS 布局中,当涉及到绝对定位的子元素与已定位父元素的关系时,定位参考基准如下(由浏览器决定的): * 父元素:绝对定位子元素的参考点是父元素的 padding box(即 border 的内边缘) * 子元素:子元素的 margin 外边缘是被定位的部分 */ class DomWrapper { _htmlEle; _node; _rotation = 0; // 存储当前旋转角度(弧度) _scaleX = 1; // 存储当前X轴缩放比例 _scaleY = 1; // 存储当前Y轴缩放比例 get htmlEle() { return this._htmlEle; } get node() { return this._node; } constructor(node, dom) { this._htmlEle = dom; this._node = node; // 同步 Node ID 到 DOM Element ID if (this._node.id) { // 如果 DOM 元素还没有 ID,或者 ID 与 Node 的 ID 不同,则设置它 if (!this._htmlEle.id || this._htmlEle.id !== this._node.id) { this._htmlEle.id = this._node.id; } } this.htmlEle.style.transformOrigin = '0 0'; // 设置变换原点为左上角 // 应用基本样式(不依赖parent) this.applyBaseStyles(); this.setLevel(node.level); // 设置层级 } // 应用基本样式 applyBaseStyles() { // 所有元素都应用的基本样式 this.htmlEle.style.boxSizing = 'border-box'; this.htmlEle.style.margin = '0'; if (this._node instanceof Group) { this.setOverflow(this._node.overflowable); // 设置溢出属性 // 去掉右键菜单 this.htmlEle.addEventListener('contextmenu', (event) => { event.preventDefault(); return false; }); } else { // 普通元素默认设为绝对定位 this.htmlEle.style.position = 'absolute'; } } // 更新定位样式 updatePositioningStyle() { if (this._node.parent) { // 有父节点时使用绝对定位 this.htmlEle.style.position = 'absolute'; if (this._node instanceof Group) { this.htmlEle.style.border = 'none'; // 去除嵌套组边框 } } else { // 是根节点时,如果没有设置定位,则设置为相对定位 const computedStyle = window.getComputedStyle(this.htmlEle); if (computedStyle.position === 'static') { this.htmlEle.style.position = 'relative'; } } } // 更新 DOM 元素的 transform 属性 _updateTransform() { let transform = ''; // 按照固定顺序应用变换(先旋转后缩放) if (this._rotation !== 0) { transform += `rotate(${this._rotation}rad) `; } if (this._scaleX !== 1 || this._scaleY !== 1) { transform += `scale(${this._scaleX}, ${this._scaleY})`; } this._htmlEle.style.transform = transform.trim(); } // 实现 IDomWrapable 接口方法 addEventListener(event, handler, options) { this._htmlEle.addEventListener(event, handler, options); } // 实现 IDomWrapable 接口方法 removeEventListener(event, handler) { this._htmlEle.removeEventListener(event, handler); } // 设置层级 setLevel(level) { if (level > 0) { this._htmlEle.style.zIndex = level.toString(); } } setOverflow(overflowable) { this._htmlEle.style.overflow = overflowable ? 'visible' : 'hidden'; } moveTo(x, y) { this._htmlEle.style.left = x + "px"; this._htmlEle.style.top = y + "px"; } rotateTo(angle) { this._rotation = angle; this._updateTransform(); } scaleTo(sx, sy) { this._scaleX = sx; this._scaleY = sy; this._updateTransform(); } setWidth(width) { this.setSize({ width }); } setHeight(height) { this.setSize({ height }); } setSize(size) { const { width, height } = size; if (width !== undefined) this._htmlEle.style.width = width + 'px'; if (height !== undefined) this._htmlEle.style.height = height + 'px'; } get offsetWidth() { return this._htmlEle.offsetWidth; } get offsetHeight() { return this._htmlEle.offsetHeight; } getBoundingClientRect() { return this._htmlEle.getBoundingClientRect(); } applyFont(font) { const { fontFamily, fontSize, fontStyle, fontWeight } = font; if (fontFamily) { this._htmlEle.style.fontFamily = fontFamily; } if (fontSize) { this._htmlEle.style.fontSize = fontSize + 'px'; } if (fontStyle) { this._htmlEle.style.fontStyle = fontStyle; } if (fontWeight) { this._htmlEle.style.fontWeight = fontWeight; } } applyColor(colorStyle) { if (colorStyle.color) { this._htmlEle.style.color = colorStyle.color; } } applyBorder(border) { const { borderWidth, borderColor, borderStyle } = border; if (borderWidth) { this._htmlEle.style.borderWidth = borderWidth + 'px'; } if (borderColor) { this._htmlEle.style.borderColor = borderColor; } if (borderStyle) {