vue2-mind-map
Version:
`vue2-mind-map` 是一个功能丰富的思维导图可视化组件。该组件提供了完整的思维导图展示和交互功能,支持多种数据格式输入、灵活的布局配置、丰富的样式定制和流畅的用户交互体验。目前基于 Vue 2 构建。
1,507 lines (1,495 loc) • 304 kB
JavaScript
(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) {