1
This commit is contained in:
@@ -0,0 +1,707 @@
|
||||
import { XTREEFLAT_CONFIG, XTREEFLAT_CHILDREN, XTREEFLAT_LAYOUT } from "./interface.uts"
|
||||
import { hexToRgb } from "../../core/util/xCoreColorUtil.uts"
|
||||
import { XTREEFLAT_NODES } from "../../interface.uts"
|
||||
type XTREE_NODES_FLAT = {
|
||||
node : XTREEFLAT_CHILDREN;
|
||||
x : number;
|
||||
y : number;
|
||||
width : number;
|
||||
height : number;
|
||||
level : number;
|
||||
parent ?: XTREEFLAT_CHILDREN;
|
||||
}
|
||||
|
||||
type XTREEFLAT_CONFIG_REAL = {
|
||||
width : number,
|
||||
height : number,
|
||||
bgColor : string,
|
||||
nodeBgColor : string,
|
||||
fontColor : string,
|
||||
fontSize : number,
|
||||
/** 统一连线颜色,不配置取节点的背景,如果节点背景也没设置取#333 */
|
||||
lineColor : string,
|
||||
/** 画板对象,必填写 **/
|
||||
canvas : CanvasContext,
|
||||
nodeInfo : NodeInfo,
|
||||
/** 绘制文本时的内边跑 */
|
||||
padding : number,
|
||||
/** 绘制项目是连接线上下项目或者左右项目的间距 */
|
||||
gutter : number,
|
||||
/** 父级与子级之间的连续长度 */
|
||||
parentLineGutter : number,
|
||||
/** 连线宽 */
|
||||
lineWidth : number,
|
||||
enbleOpenChildren : boolean,
|
||||
nodeRadius : number,
|
||||
/** 布局方式 */
|
||||
layout : XTREEFLAT_LAYOUT
|
||||
}
|
||||
type calculateNodeSize_return = { width : number, height : number }
|
||||
type xCalcListDataFun = (list : XTREEFLAT_NODES[]) => XTREEFLAT_CHILDREN[]
|
||||
type traverseFun = (node : XTREEFLAT_CHILDREN, level : number) => void
|
||||
type drawRealRect = { x : number, y : number, width : number, height : number }
|
||||
|
||||
export class OrgChartRenderer {
|
||||
private ctx : CanvasRenderingContext2D;
|
||||
private nodes = [] as Array<XTREE_NODES_FLAT>;
|
||||
private offsetX = 0;
|
||||
private offsetY = 0;
|
||||
private config : XTREEFLAT_CONFIG_REAL
|
||||
private pixelRatio = 1;
|
||||
private levelMaxWidth = {} as UTSJSONObject
|
||||
private data = [] as XTREEFLAT_CHILDREN[]
|
||||
public scaleXY = 1.0;
|
||||
private nowSelectedNodes = null as null | XTREEFLAT_CHILDREN
|
||||
private nodesClick = (item : XTREEFLAT_CHILDREN) => { }
|
||||
constructor(xconfig : XTREEFLAT_CONFIG) {
|
||||
this.config = {
|
||||
width: xconfig?.width ?? 1000,
|
||||
height: xconfig?.height ?? 1000,
|
||||
bgColor: xconfig?.bgColor ?? 'rgba(0,0,0,0)',
|
||||
fontColor: xconfig?.fontColor ?? '#2f2943',
|
||||
lineColor: xconfig?.lineColor ?? 'rgb(220, 215, 255)',
|
||||
nodeBgColor: xconfig?.nodeBgColor ?? 'rgb(220, 215, 255)',
|
||||
padding: xconfig?.padding ?? 10,
|
||||
gutter: xconfig?.gutter ?? 12,
|
||||
nodeRadius: xconfig?.nodeRadius ?? 8,
|
||||
parentLineGutter: xconfig?.parentLineGutter ?? 30,
|
||||
enbleOpenChildren: xconfig?.enbleOpenChildren ?? true,
|
||||
lineWidth: xconfig?.lineWidth ?? 1.5,
|
||||
canvas: xconfig.canvas,
|
||||
nodeInfo: xconfig.nodeInfo,
|
||||
fontSize: xconfig?.fontSize ?? 13,
|
||||
layout: (xconfig?.layout ?? 'horizontal') as XTREEFLAT_LAYOUT,
|
||||
} as XTREEFLAT_CONFIG_REAL
|
||||
|
||||
const canvasContext = this.config.canvas.getContext('2d')!;
|
||||
const canvas = canvasContext.canvas;
|
||||
// 处理高清屏逻辑
|
||||
const dpr = uni.getWindowInfo().pixelRatio;
|
||||
canvas.width = canvas.offsetWidth * dpr;
|
||||
canvas.height = canvas.offsetHeight * dpr;
|
||||
canvasContext.scale(dpr, dpr);
|
||||
this.ctx = canvasContext;
|
||||
this.pixelRatio = dpr;
|
||||
|
||||
}
|
||||
|
||||
public getDrawBounds() : drawRealRect {
|
||||
if (this.nodes.length === 0) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
} as drawRealRect
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
this.nodes.forEach(({ x, y, width, height }) => {
|
||||
// #ifndef APP-ANDROID
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, (x + width));
|
||||
maxY = Math.max(maxY, (y + height));
|
||||
// #endif
|
||||
// #ifdef APP-ANDROID
|
||||
minX = Math.min(minX.toDouble(), x.toDouble()).toDouble();
|
||||
minY = Math.min(minY.toDouble(), y.toDouble()).toDouble();
|
||||
maxX = Math.max(maxX.toDouble(), (x + width).toDouble()).toDouble();
|
||||
maxY = Math.max(maxY.toDouble(), (y + height).toDouble()).toDouble();
|
||||
// #endif
|
||||
});
|
||||
|
||||
// 加上偏移量得到实际绘制位置
|
||||
return {
|
||||
x: minX + this.offsetX,
|
||||
y: minY + this.offsetY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY
|
||||
} as drawRealRect
|
||||
}
|
||||
|
||||
private wrapText(text : string, fontSize : number) : string[] {
|
||||
this.ctx.font = `${fontSize}px Arial`;
|
||||
const words = text.split('');
|
||||
const lines : string[] = [];
|
||||
let currentLine = '';
|
||||
// 每行最多8个字符
|
||||
const maxCharsPerLine = 8;
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (currentLine.length >= maxCharsPerLine) {
|
||||
lines.push(currentLine);
|
||||
currentLine = '';
|
||||
}
|
||||
currentLine += words[i];
|
||||
}
|
||||
if (currentLine != '') {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
private calcMeasureText(text : string, fontSize : number) : number {
|
||||
let totalWidth = 0;
|
||||
// #ifdef APP-HARMONY
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charAt(i);
|
||||
const charCode = char.charCodeAt(0);
|
||||
|
||||
// 判断是否为中文字符(包括中文标点符号)
|
||||
// 中文字符的Unicode范围:0x4e00-0x9fff(基本汉字)
|
||||
// 中文标点符号:0x3000-0x303f, 0xff00-0xffef
|
||||
if ((charCode >= 0x4e00 && charCode <= 0x9fff) ||
|
||||
(charCode >= 0x3000 && charCode <= 0x303f) ||
|
||||
(charCode >= 0xff00 && charCode <= 0xffef)) {
|
||||
// 中文字符时取字号大小
|
||||
totalWidth += fontSize;
|
||||
} else {
|
||||
// 非中文符号时取字符数量*字号大小的一半
|
||||
totalWidth += fontSize * 0.5;
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
return totalWidth;
|
||||
}
|
||||
private calculateNodeSize(node : XTREEFLAT_CHILDREN) : calculateNodeSize_return {
|
||||
const lines = this.wrapText(node.title, node.fontSize);
|
||||
this.ctx.font = `${node.fontSize}px Arial`;
|
||||
// 计算最宽的行的宽度
|
||||
let linesWidths : number[] = lines.map((line) : number => {
|
||||
let w = this.ctx.measureText(line).width;
|
||||
// #ifdef APP-HARMONY
|
||||
w = this.calcMeasureText(line,node.fontSize);
|
||||
// #endif
|
||||
return w;
|
||||
});
|
||||
const maxWidth = Math.max(...linesWidths);
|
||||
// 高度需要考虑行数和行间距
|
||||
const lineHeight = node.fontSize * 1.2; // 添加1.2倍行高
|
||||
const textHeight = lineHeight * lines.length;
|
||||
return {
|
||||
width: maxWidth + node.padding * 2, // 两侧添加padding
|
||||
height: textHeight + node.padding * 2 // 上下添加padding
|
||||
} as calculateNodeSize_return;
|
||||
}
|
||||
// 计算节点及其子树需要的总高度
|
||||
private calculateSubtreeHeight(node : XTREEFLAT_CHILDREN) : number {
|
||||
const nodeSize = this.calculateNodeSize(node);
|
||||
if (!node.opened || node.children.length == 0) {
|
||||
return nodeSize.height;
|
||||
}
|
||||
// 计算所有子节点的高度总和(包括间距)
|
||||
const childrenHeight = node.children.reduce((total, child, index) => {
|
||||
const childHeight = this.calculateSubtreeHeight(child);
|
||||
return total + childHeight + (index < node.children.length - 1 ? node.gutter : 0);
|
||||
}, 0);
|
||||
// 返回较大的值:节点自身高度,或子节点总高度
|
||||
return Math.max(nodeSize.height, childrenHeight);
|
||||
}
|
||||
// 收集各层级的最大宽度
|
||||
private collectLevelMaxWidth() {
|
||||
this.levelMaxWidth = {} as UTSJSONObject;
|
||||
let traverse = null as traverseFun | null;
|
||||
traverse = (node : XTREEFLAT_CHILDREN, level : number) => {
|
||||
const size = this.calculateNodeSize(node);
|
||||
const currentWidth = size.width;
|
||||
if (this.levelMaxWidth.get(level.toString()) == null) {
|
||||
this.levelMaxWidth.set(level.toString(), currentWidth)
|
||||
}
|
||||
let maxwidth = this.levelMaxWidth.get(level.toString())! as number;
|
||||
if (currentWidth > maxwidth) {
|
||||
this.levelMaxWidth.set(level.toString(), currentWidth)
|
||||
}
|
||||
if (node.opened && node.children.length > 0) {
|
||||
node.children.forEach(child => traverse!(child, level + 1));
|
||||
}
|
||||
};
|
||||
this.data.forEach(root => traverse!(root, 0));
|
||||
}
|
||||
|
||||
private layoutNode(
|
||||
node : XTREEFLAT_CHILDREN,
|
||||
parentX : number,
|
||||
parentY : number,
|
||||
level : number,
|
||||
parent : XTREEFLAT_CHILDREN | null = null
|
||||
) {
|
||||
const size = this.calculateNodeSize(node);
|
||||
const nodeWidth = (this.levelMaxWidth.getNumber(level.toString()))!;
|
||||
const subtreeHeight = this.calculateSubtreeHeight(node);
|
||||
// 将节点添加到nodes数组,注意这里y坐标的计算
|
||||
this.nodes.push({
|
||||
node,
|
||||
x: parentX,
|
||||
y: parentY - size.height / 2, // 修改:直接使用节点自身高度居中
|
||||
width: nodeWidth,
|
||||
height: size.height,
|
||||
level,
|
||||
parent
|
||||
} as XTREE_NODES_FLAT);
|
||||
if (node.opened && node.children.length > 0) {
|
||||
// 计算所有子节点的总高度
|
||||
const totalChildrenHeight = node.children.reduce((total, child) => {
|
||||
return total + this.calculateSubtreeHeight(child);
|
||||
}, 0);
|
||||
// 添加子节点之间的间距
|
||||
const totalGutterHeight = (node.children.length - 1) * node.gutter;
|
||||
const totalHeight = totalChildrenHeight + totalGutterHeight;
|
||||
// 修改:计算子节点的起始y坐标,使其相对父节点居中
|
||||
let childY = parentY - totalHeight / 2;
|
||||
// 为每个子节点布局
|
||||
node.children.forEach((child, index) => {
|
||||
const childSubtreeHeight = this.calculateSubtreeHeight(child);
|
||||
// 修改:将子节点放置在计算好的位置上
|
||||
this.layoutNode(
|
||||
child,
|
||||
(parentX + nodeWidth + node.parentLineGutter),
|
||||
(childY + childSubtreeHeight / 2), // 修改:确保子节点在其分配空间内居中
|
||||
level + 1,
|
||||
node
|
||||
);
|
||||
// 更新下一个子节点的y坐标
|
||||
childY += childSubtreeHeight + node.gutter;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算节点及其子树需要的总宽(用于垂直布局)
|
||||
private calculateSubtreeWidth(node : XTREEFLAT_CHILDREN) : number {
|
||||
const nodeSize = this.calculateNodeSize(node);
|
||||
if (!node.opened || node.children.length == 0) {
|
||||
return nodeSize.width;
|
||||
}
|
||||
const childrenWidth = node.children.reduce((total, child, index) => {
|
||||
const childWidth = this.calculateSubtreeWidth(child);
|
||||
return total + childWidth + (index < node.children.length - 1 ? node.gutter : 0);
|
||||
}, 0);
|
||||
return Math.max(nodeSize.width, childrenWidth);
|
||||
}
|
||||
|
||||
// 垂直布局:同级左右排列,次级向下
|
||||
private layoutNodeVertical(
|
||||
node : XTREEFLAT_CHILDREN,
|
||||
parentCenterX : number,
|
||||
parentTopY : number,
|
||||
level : number,
|
||||
parent : XTREEFLAT_CHILDREN | null = null
|
||||
) {
|
||||
const size = this.calculateNodeSize(node);
|
||||
const nodeLeftX = parentCenterX - size.width / 2;
|
||||
this.nodes.push({
|
||||
node,
|
||||
x: nodeLeftX,
|
||||
y: parentTopY,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
level,
|
||||
parent
|
||||
} as XTREE_NODES_FLAT);
|
||||
|
||||
if (node.opened && node.children.length > 0) {
|
||||
const totalChildrenWidth = node.children.reduce((total, child) => {
|
||||
return total + this.calculateSubtreeWidth(child);
|
||||
}, 0);
|
||||
const totalGutterWidth = (node.children.length - 1) * node.gutter;
|
||||
const totalWidth = totalChildrenWidth + totalGutterWidth;
|
||||
let childLeft = parentCenterX - totalWidth / 2;
|
||||
node.children.forEach((child) => {
|
||||
const childSubtreeWidth = this.calculateSubtreeWidth(child);
|
||||
const childCenterX = childLeft + childSubtreeWidth / 2;
|
||||
this.layoutNodeVertical(
|
||||
child,
|
||||
childCenterX,
|
||||
parentTopY + size.height + node.parentLineGutter,
|
||||
level + 1,
|
||||
node
|
||||
);
|
||||
childLeft += childSubtreeWidth + node.gutter;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private calculateLayout() {
|
||||
this.nodes = [];
|
||||
if (this.config.layout == 'horizontal') {
|
||||
this.collectLevelMaxWidth();
|
||||
const totalHeight = this.data.reduce((total, root, index) => {
|
||||
const subtreeHeight = this.calculateSubtreeHeight(root);
|
||||
return total + subtreeHeight + (index < this.data.length - 1 ? root.gutter : 0);
|
||||
}, 0);
|
||||
let startY = -totalHeight / 2;
|
||||
this.data.forEach((root) => {
|
||||
const subtreeHeight = this.calculateSubtreeHeight(root);
|
||||
this.layoutNode(
|
||||
root,
|
||||
0,
|
||||
startY + subtreeHeight / 2,
|
||||
0,
|
||||
null
|
||||
);
|
||||
startY += subtreeHeight + root.gutter;
|
||||
});
|
||||
} else {
|
||||
|
||||
const totalWidth = this.data.reduce((total, root, index) => {
|
||||
const subtreeWidth = this.calculateSubtreeWidth(root);
|
||||
return total + subtreeWidth + (index < this.data.length - 1 ? root.gutter : 0);
|
||||
}, 0);
|
||||
let startX = -totalWidth / 2;
|
||||
this.data.forEach((root) => {
|
||||
const subtreeWidth = this.calculateSubtreeWidth(root);
|
||||
const rootCenterX = startX + subtreeWidth / 2;
|
||||
this.layoutNodeVertical(
|
||||
root,
|
||||
rootCenterX,
|
||||
0,
|
||||
0,
|
||||
null
|
||||
);
|
||||
startX += subtreeWidth + root.gutter;
|
||||
});
|
||||
}
|
||||
|
||||
// 计算边界框并设置偏移
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
this.nodes.forEach(({ x, y, width, height }) => {
|
||||
// #ifndef APP-ANDROID
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, (x + width));
|
||||
maxY = Math.max(maxY, (y + height));
|
||||
// #endif
|
||||
// #ifdef APP-ANDROID
|
||||
minX = Math.min(minX.toDouble(), x.toDouble()).toDouble();
|
||||
minY = Math.min(minY.toDouble(), y.toDouble()).toDouble();
|
||||
maxX = Math.max(maxX.toDouble(), (x + width).toDouble()).toDouble();
|
||||
maxY = Math.max(maxY.toDouble(), (y + height).toDouble()).toDouble();
|
||||
// #endif
|
||||
});
|
||||
|
||||
// 计算居中偏移
|
||||
this.offsetX = (this.config.width - (maxX - minX)) / 2 - minX;
|
||||
this.offsetY = (this.config.height - (maxY - minY)) / 2 - minY;
|
||||
}
|
||||
|
||||
private drawConnections(nodeMeta : XTREE_NODES_FLAT) {
|
||||
const node = nodeMeta.node
|
||||
const x = nodeMeta.x
|
||||
const y = nodeMeta.y
|
||||
const width = nodeMeta.width
|
||||
const height = nodeMeta.height
|
||||
const level = nodeMeta.level
|
||||
const parent = nodeMeta.parent
|
||||
// 设置连接线样式
|
||||
this.ctx.strokeStyle = nodeMeta.node.bgColor;
|
||||
this.ctx.lineWidth = this.config.lineWidth;
|
||||
// 如果有父节点,绘制到父节点的连接(根据布局切换)
|
||||
if (parent != null && this.config.layout == 'horizontal') {
|
||||
const parentMetas = this.nodes.find((n) : boolean => n.node.id == parent.id);
|
||||
|
||||
if (parentMetas != null) {
|
||||
const parentMeta = parentMetas!
|
||||
const startX = x;
|
||||
const startY = y + height / 2;
|
||||
const endX = parentMeta.x + parentMeta.width;
|
||||
const endY = parentMeta.y + parentMeta.height / 2;
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(startX, startY);
|
||||
this.ctx.lineTo(startX - node.parentLineGutter / 2, startY);
|
||||
this.ctx.lineTo(startX - node.parentLineGutter / 2, endY);
|
||||
this.ctx.lineTo(endX, endY);
|
||||
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
// vertical 模式下不需要在子节点中绘制到父节点的连接线
|
||||
// 父节点会负责绘制到所有子节点的连接线
|
||||
|
||||
// 绘制同级节点之间的连接线
|
||||
if (this.config.layout == 'horizontal' && level == 0) {
|
||||
// 查找所有根节点
|
||||
const rootNodes = this.nodes.filter(n => n.level === 0);
|
||||
if (rootNodes.length > 1) {
|
||||
const index = rootNodes.findIndex(n => n.node === node);
|
||||
if (index > 0) {
|
||||
const prevNode = rootNodes[index - 1]!;
|
||||
const midY = (prevNode.y + prevNode.height / 2 + y + height / 2) / 2;
|
||||
|
||||
this.ctx.beginPath();
|
||||
|
||||
this.ctx.moveTo(prevNode.x + prevNode.width / 2, prevNode.y + prevNode.height / 2);
|
||||
this.ctx.lineTo(prevNode.x + prevNode.width / 2, midY);
|
||||
this.ctx.lineTo(x + width / 2, midY);
|
||||
this.ctx.lineTo(x + width / 2, y + height / 2);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制同级节点之间的连接线(垂直布局)
|
||||
if (this.config.layout == 'vertical' && level == 0) {
|
||||
// 查找所有根节点
|
||||
const rootNodes = this.nodes.filter(n => n.level === 0);
|
||||
if (rootNodes.length > 1) {
|
||||
const index = rootNodes.findIndex(n => n.node === node);
|
||||
if (index > 0) {
|
||||
const prevNode = rootNodes[index - 1]!;
|
||||
const midX = (prevNode.x + prevNode.width / 2 + x + width / 2) / 2;
|
||||
|
||||
this.ctx.beginPath();
|
||||
|
||||
this.ctx.moveTo(prevNode.x + prevNode.width / 2, prevNode.y + prevNode.height / 2);
|
||||
this.ctx.lineTo(midX, prevNode.y + prevNode.height / 2);
|
||||
this.ctx.lineTo(midX, y + height / 2);
|
||||
this.ctx.lineTo(x + width / 2, y + height / 2);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果节点展开且有子节点,绘制子节点连接线
|
||||
if (node.opened && node.children.length > 0 && this.config.layout == 'horizontal') {
|
||||
const childNodes = this.nodes.filter(n => n.parent === node);
|
||||
if (childNodes.length > 0) {
|
||||
// 绘制垂直连接线
|
||||
const firstChild = childNodes[0]!;
|
||||
const lastChild = childNodes[childNodes.length - 1]!;
|
||||
const lineX = x + width + node.parentLineGutter / 2;
|
||||
|
||||
this.ctx.beginPath();
|
||||
|
||||
this.ctx.moveTo(lineX, firstChild.y + firstChild.height / 2);
|
||||
this.ctx.lineTo(lineX, lastChild.y + lastChild.height / 2);
|
||||
this.ctx.stroke();
|
||||
|
||||
// 绘制到每个子节点的水平连接线
|
||||
|
||||
childNodes.forEach(child => {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(lineX, child.y + child.height / 2);
|
||||
this.ctx.lineTo(child.x, child.y + child.height / 2);
|
||||
this.ctx.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (node.children.length > 0 && this.config.layout == 'vertical') {
|
||||
const childNodes = this.nodes.filter(n => n.parent === node);
|
||||
if (childNodes.length > 0) {
|
||||
const firstChild = childNodes[0]!;
|
||||
const lastChild = childNodes[childNodes.length - 1]!;
|
||||
const midY = y + height + node.parentLineGutter / 2;
|
||||
// 父竖线
|
||||
const parentCenterX = x + width / 2;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(parentCenterX, y + height);
|
||||
this.ctx.lineTo(parentCenterX, midY);
|
||||
this.ctx.stroke();
|
||||
// 水平干线
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(firstChild.x + firstChild.width / 2, midY);
|
||||
this.ctx.lineTo(lastChild.x + lastChild.width / 2, midY);
|
||||
this.ctx.stroke();
|
||||
// 子竖线
|
||||
childNodes.forEach(child => {
|
||||
const childCenterX = child.x + child.width / 2;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(childCenterX, child.y);
|
||||
this.ctx.lineTo(childCenterX, midY);
|
||||
this.ctx.stroke();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
drawRoundedRect(x : number, y : number, width : number, height : number, radius : number) {
|
||||
const ctx = this.ctx;
|
||||
ctx.beginPath();
|
||||
// 左上角开始
|
||||
ctx.moveTo(x + radius, y);
|
||||
// 上边 + 右上角圆弧
|
||||
ctx.arcTo(x + width, y, x + width, y + height, radius);
|
||||
// 右边 + 右下角圆弧
|
||||
ctx.arcTo(x + width, y + height, x, y + height, radius);
|
||||
// 下边 + 左下角圆弧
|
||||
ctx.arcTo(x, y + height, x, y, radius);
|
||||
// 左边 + 左上角圆弧
|
||||
ctx.arcTo(x, y, x + width, y, radius);
|
||||
ctx.closePath();
|
||||
ctx.fill(); // 或使用 stroke() 绘制边框
|
||||
}
|
||||
private draw() {
|
||||
this.ctx.clearRect(0, 0, this.config.width, this.config.height);
|
||||
this.ctx.fillStyle = this.config.bgColor;
|
||||
this.ctx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
this.ctx.save();
|
||||
|
||||
this.ctx.translate(this.offsetX, this.offsetY);
|
||||
// 先绘制连接线
|
||||
this.nodes.forEach(nodeMeta => this.drawConnections(nodeMeta));
|
||||
// 再绘制节点(确保节点在连接线上层)
|
||||
this.nodes.forEach(nodeMeta => {
|
||||
const { node, x, y, width, height } = nodeMeta;
|
||||
|
||||
// 绘制选中状态
|
||||
if (node.selected) {
|
||||
this.ctx.fillStyle = node.lineColor;
|
||||
this.drawRoundedRect(x - 5, y - 5, width + 10, height + 10, this.config.nodeRadius)
|
||||
}
|
||||
|
||||
// 绘制背景
|
||||
this.ctx.fillStyle = node.bgColor;
|
||||
this.drawRoundedRect(x, y, width, height, this.config.nodeRadius)
|
||||
|
||||
// 绘制文本(使用换行)
|
||||
const lines = this.wrapText(node.title, node.fontSize);
|
||||
this.ctx.fillStyle = node.fontColor;
|
||||
this.ctx.font = `${node.fontSize}px Arial`;
|
||||
this.ctx.textBaseline = 'middle';
|
||||
|
||||
const lineHeight = node.fontSize * 1.2;
|
||||
|
||||
const totalTextHeight = lineHeight * lines.length;
|
||||
const textStartY = y + (height - totalTextHeight) / 2;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
|
||||
const lineY = textStartY + lineHeight * index + lineHeight / 2;
|
||||
let textWidth = this.ctx.measureText(line).width;
|
||||
// #ifdef APP-HARMONY
|
||||
textWidth = this.calcMeasureText(line,node.fontSize);
|
||||
// #endif
|
||||
const textX = x + (width - textWidth) / 2;
|
||||
this.ctx.fillText(line, textX, lineY);
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
// 处理数据
|
||||
private updateData(list : XTREEFLAT_NODES[]) {
|
||||
let calcListData = null as xCalcListDataFun | null;
|
||||
let _this = this;
|
||||
calcListData = (datas : XTREEFLAT_NODES[]) : XTREEFLAT_CHILDREN[] => {
|
||||
if (datas.length == 0) return [] as XTREEFLAT_CHILDREN[];
|
||||
let tem = [] as XTREEFLAT_CHILDREN[]
|
||||
let calcfun = calcListData!
|
||||
for (let i = 0; i < datas.length; i++) {
|
||||
let item = datas[i];
|
||||
let children = item?.children ?? ([] as XTREEFLAT_NODES[])
|
||||
|
||||
let bgcolor = item?.bgColor ?? _this.config.nodeBgColor
|
||||
let rgba = hexToRgb(bgcolor)
|
||||
let lineColor = `rgba(${rgba.getNumber('r')},${rgba.getNumber('g')},${rgba.getNumber('b')},0.4)`
|
||||
tem.push({
|
||||
title: item.title,
|
||||
id: item?.id ?? ("xTreeFlatNodes-" + Math.random().toString(16).substring(4, 20)),
|
||||
fontColor: item?.fontColor ?? _this.config.fontColor,
|
||||
bgColor: bgcolor,
|
||||
lineColor: lineColor,
|
||||
padding: item?.padding ?? _this.config.padding,
|
||||
gutter: item?.gutter ?? _this.config.gutter,
|
||||
parentLineGutter: item?.parentLineGutter ?? _this.config.parentLineGutter,
|
||||
opened: item?.opened ?? true,
|
||||
disabled: item?.disabled ?? _this.config.enbleOpenChildren,
|
||||
selected: item?.selected ?? false,
|
||||
fontSize: item?.fontSize ?? _this.config.fontSize,
|
||||
children: calcfun(children)
|
||||
} as XTREEFLAT_CHILDREN)
|
||||
}
|
||||
return tem;
|
||||
}
|
||||
let calcfun = calcListData!
|
||||
this.data = calcfun(list)
|
||||
}
|
||||
|
||||
// 计算自动缩放比例
|
||||
private calculateAutoScale() : number {
|
||||
const bounds = this.getDrawBounds();
|
||||
if (bounds.width === 0 || bounds.height === 0) return 1.0;
|
||||
|
||||
// 考虑边距,留出10%的空间
|
||||
const padding = 0.1;
|
||||
const containerWidth = this.config.nodeInfo.width! * (1 - padding);
|
||||
const containerHeight = this.config.nodeInfo.height! * (1 - padding);
|
||||
|
||||
// 计算水平和垂直方向的缩放比例
|
||||
const scaleX = containerWidth / bounds.width;
|
||||
const scaleY = containerHeight / bounds.height;
|
||||
|
||||
// 取较小的缩放比例,确保完整显示
|
||||
return Math.min(scaleX, scaleY);
|
||||
}
|
||||
|
||||
// 设置缩放比例的公共方法
|
||||
public setScale(scale : number | null = null) {
|
||||
// 如果没有提供缩放比例,则自动计算
|
||||
this.scaleXY = scale == null ? this.calculateAutoScale() : (scale!);
|
||||
this.draw();
|
||||
}
|
||||
public addEventClick(e : UniPointerEvent, scrollTop : number, scrollLeft : number) {
|
||||
if (this.nodes.length == 0) return;
|
||||
let wintop = uni.getWindowInfo().windowTop;
|
||||
const containerX = e.clientX - (this.config.nodeInfo.left ?? 0)
|
||||
const containerY = e.clientY - (this.config.nodeInfo.top ?? 0) - wintop
|
||||
|
||||
const canvasX = (containerX) + scrollLeft - this.offsetX;
|
||||
const canvasY = (containerY) + scrollTop - this.offsetY;
|
||||
//是否命中了节点.0未操作,1命中,
|
||||
let isHit = false
|
||||
//检测点击的节点
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
const meta = this.nodes[i];
|
||||
if (canvasX >= meta.x && canvasX <= meta.x + meta.width &&
|
||||
canvasY >= meta.y && canvasY <= meta.y + meta.height) {
|
||||
if (!meta.node.disabled) {
|
||||
meta.node.opened = !meta.node.opened;
|
||||
this.calculateLayout();
|
||||
}
|
||||
// 更新选中状态
|
||||
this.nodes.forEach(m => {
|
||||
m.node.selected = false
|
||||
if (m.node.id == meta.node.id) {
|
||||
m.node.selected = true;
|
||||
}
|
||||
});
|
||||
this.draw();
|
||||
isHit = true;
|
||||
this.nowSelectedNodes = meta.node
|
||||
//命中的情况下触发选中事件.
|
||||
this.nodesClick(meta.node)
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 点击空白时,如果有选中的项目就清除所有选中,而且必须是未命中的情况
|
||||
if (this.nowSelectedNodes != null && !isHit) {
|
||||
this.nodes.forEach(m => {
|
||||
m.node.selected = false
|
||||
});
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
||||
if (!isHit) {
|
||||
this.nowSelectedNodes = null
|
||||
}
|
||||
|
||||
}
|
||||
public onListen(call : (item : XTREEFLAT_CHILDREN) => void) {
|
||||
this.nodesClick = call;
|
||||
}
|
||||
|
||||
public setData(newData : XTREEFLAT_NODES[]) {
|
||||
this.updateData(newData)
|
||||
this.calculateLayout();
|
||||
this.draw();
|
||||
// this.setScale()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
|
||||
export type XTREEFLAT_LAYOUT = 'horizontal' | 'vertical'
|
||||
|
||||
export type XTREEFLAT_CONFIG = {
|
||||
/** 不填写默认为1000 */
|
||||
width?:number,
|
||||
/** 不填写默认为1000 */
|
||||
height?:number,
|
||||
/** 不填写默认为透明,即不绘制背景 */
|
||||
bgColor?:string,
|
||||
nodeBgColor?:string,
|
||||
/** 不填写默认为#333 */
|
||||
fontColor?:string,
|
||||
/** 不填写默认为14,如果子节点设置了以子节点为准 */
|
||||
fontSize ?: number,
|
||||
/** 统一连线颜色,不配置取节点的背景,如果节点背景也没设置取#333 */
|
||||
lineColor?:string,
|
||||
/** 连线宽 */
|
||||
lineWidth?:number,
|
||||
/** 画板对象,必填写 **/
|
||||
canvas:CanvasContext,
|
||||
/** 节点布局信息 **/
|
||||
nodeInfo:NodeInfo,
|
||||
/** 绘制文本时的内边跑 */
|
||||
padding ?: number,
|
||||
/** 绘制项目是连接线上下项目或者左右项目的间距 */
|
||||
gutter ?: number,
|
||||
/** 父级与子级之间的连续长度 */
|
||||
parentLineGutter ?: number,
|
||||
/** 点击有子节点的父级时,是否允许关闭和展开,默认关闭 */
|
||||
enbleOpenChildren ?:boolean,
|
||||
/** 节点圆角,默认为8 */
|
||||
nodeRadius ?: number,
|
||||
/** 布局方式:horizontal 水平(默认),vertical 垂直 */
|
||||
layout?: XTREEFLAT_LAYOUT
|
||||
}
|
||||
|
||||
|
||||
export type XTREEFLAT_CHILDREN = {
|
||||
/** 唯一标识,如果不填写内部自动填写 */
|
||||
id : string | number,
|
||||
/** 标题 */
|
||||
title : string,
|
||||
/** 文本颜色 */
|
||||
fontColor : string,
|
||||
/** 文本背景颜色 */
|
||||
bgColor : string,
|
||||
/** 绘制文本时的内边跑 */
|
||||
padding : number,
|
||||
/** 绘制项目是连接线上下项目或者左右项目的间距 */
|
||||
gutter : number,
|
||||
/** 父级与子级之间的连续长度 */
|
||||
parentLineGutter : number,
|
||||
/** 是否展开了子级 */
|
||||
opened : boolean,
|
||||
/** 是否禁用展开子级 */
|
||||
disabled : boolean,
|
||||
/** 当前项目是否选中 */
|
||||
selected : boolean,
|
||||
/** 绘制文本的字号大小 */
|
||||
fontSize : number,
|
||||
/** 子级数据,可以为空数组代表无子级不绘制下级 */
|
||||
children : Array<XTREEFLAT_CHILDREN>,
|
||||
lineColor : string
|
||||
}
|
||||
|
||||
export type propsTypeAsSelef = {
|
||||
/** 画板宽,总宽不要小于节点展开后的宽,可以尽量设置大点 */
|
||||
canvasWidth:number,
|
||||
/** 画板高,总高不要小于节点展开后的高,可以尽量设置大点 */
|
||||
canvasHeight:number,
|
||||
/** 组件宽 */
|
||||
width:string,
|
||||
/** 组件高,不能为auto */
|
||||
height:string,
|
||||
/** 动态数据 */
|
||||
list:XTREEFLAT_NODES[],
|
||||
opts:XTreeFlatOpts|null
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<!-- #ifdef APP -->
|
||||
<scroll-view
|
||||
@scroll="onScrollTop"
|
||||
class="xTreeFlatBox"
|
||||
:scroll-top="scrollTop"
|
||||
:scroll-y="true" direction="vertical" :style="{width:_width,height:_height}"
|
||||
>
|
||||
<scroll-view @scroll="onScrollLeft" :scroll-left="scrollLeft" :scroll-x="true" direction="horizontal" :style="{width: _width,height: canvasHeight+'px'}">
|
||||
<canvas :key="keyids" @click="onclickCanvas" :id="id" ref="canvas" :style="{width:canvasWidth+'px',height:canvasHeight+'px'}"></canvas>
|
||||
</scroll-view>
|
||||
</scroll-view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP -->
|
||||
<view ref="xTreeFlatBox" class="xTreeFlatBox" :style="{width:_width,height:_height}">
|
||||
<scroll-view @scroll="onScrollAll" class="xTreeFlatBoxScroll" :scroll-top="scrollTop" :scroll-left="scrollLeft" :scroll-x="true" :scroll-y="true" direction="all" :style="{width: '100%',height: '100%'}">
|
||||
<canvas :key="keyids" @click="onclickCanvas" :id="id" ref="canvas" :style="{width:canvasWidth+'px',height:canvasHeight+'px'}"></canvas>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef WEB -->
|
||||
<view ref="xTreeFlatBox" class="xTreeFlatBox" :style="{width:_width,height:_height}">
|
||||
<canvas :key="keyids" @click="onclickCanvas" :id="id" ref="canvas" :style="{width:canvasWidth+'px',height:canvasHeight+'px'}"></canvas>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { computed, ref, onMounted,PropType,getCurrentInstance,nextTick } from "vue"
|
||||
import { xDate } from "../../core/util/xDate.uts"
|
||||
import { checkIsCssUnit, getUnit } from "../../core/util/xCoreUtil.uts"
|
||||
import { getDefaultColor } from "../../core/util/xCoreColorUtil.uts"
|
||||
import { xConfig } from "../../config/xConfig.uts"
|
||||
import { XTREEFLAT_NODES,XTreeFlatOpts } from "../../interface.uts"
|
||||
import {XTREEFLAT_CONFIG,XTREEFLAT_CHILDREN,propsTypeAsSelef } from "./interface.uts"
|
||||
import { OrgChartRenderer } from "./draw.uts"
|
||||
|
||||
/**
|
||||
* @name 思维导图 xTreeFlat
|
||||
* @page /pages/index/tree-flat
|
||||
* @category 其它组件
|
||||
* @description 主要用来展示平面树平面结构,思维导图的展示
|
||||
* @constant 平台兼容
|
||||
* | Harmony | H5 | andriod | IOS | 小程序 | UTS | UNIAPP-X SDK | version |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| ☑ | ☑ | ☑️ | ☑️ | ☑️ | ☑️ | 4.76+ | 1.1.18 |
|
||||
*/
|
||||
defineOptions({name:"xTreeFlat"})
|
||||
|
||||
const xTreeFlatBox = ref<UniElement | null>(null)
|
||||
|
||||
const id = "xTreeFlat-"+Math.random().toString(16).substring(4,20)
|
||||
const proxy = getCurrentInstance()?.proxy??null;
|
||||
const scrollLeft = ref(0)
|
||||
const scrollTop = ref(0)
|
||||
const scrollLeft_onlyRead = ref(0)
|
||||
const scrollTop_onlyRead = ref(0)
|
||||
const boxNodeinfo = ref<NodeInfo|null>(null)
|
||||
let tid = 23
|
||||
const props = withDefaults(defineProps<propsTypeAsSelef>(),{
|
||||
/**
|
||||
* 画板宽,总宽不要小于节点展开后的宽,可以尽量设置大点
|
||||
*/
|
||||
canvasWidth:800,
|
||||
/**
|
||||
* 画板高,总高不要小于节点展开后的高,可以尽量设置大点
|
||||
*/
|
||||
canvasHeight:800,
|
||||
/**
|
||||
* 组件宽,可以为auto,百分比,数字字符,带单位字符
|
||||
*/
|
||||
width:'100%',
|
||||
/**
|
||||
* 组件高,不能为auto
|
||||
*/
|
||||
height:'600',
|
||||
/**
|
||||
* 动态数据
|
||||
*/
|
||||
list:[] as XTREEFLAT_NODES[],
|
||||
/**
|
||||
* 配置见类型:XTreeFlatOpts ,建议使用ref方法setData来设置
|
||||
*/
|
||||
opts:null as XTreeFlatOpts|null
|
||||
})
|
||||
|
||||
|
||||
|
||||
const _width = computed(():string=>checkIsCssUnit(props.width,xConfig.unit))
|
||||
const _height = computed(():string=>checkIsCssUnit(props.height,xConfig.unit))
|
||||
const render = ref<null|OrgChartRenderer>(null)
|
||||
const canvasWidth = ref(props.canvasWidth)
|
||||
const canvasHeight = ref(props.canvasHeight)
|
||||
const keyids = ref(23)
|
||||
const emits = defineEmits([
|
||||
/**
|
||||
* 引擎初始化完成,可以进行通过ref来异步设置数据
|
||||
*/
|
||||
'init',
|
||||
/** 当list数据被编辑变动时触发 */
|
||||
'change',
|
||||
/**
|
||||
* 当前节点被点击时触发
|
||||
* @param {item:XTREEFLAT_CHILDREN} 被点击的节点数据
|
||||
*/
|
||||
'click'
|
||||
])
|
||||
const setScrollPosition = ()=>{
|
||||
// #ifdef WEB
|
||||
let ele = xTreeFlatBox.value;
|
||||
if(ele==null) return;
|
||||
ele.scrollLeft = scrollLeft.value
|
||||
ele.scrollTop = scrollTop.value
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置数据
|
||||
* @param {XTREEFLAT_NODES[]} list 设置当前数据
|
||||
*/
|
||||
const setData = (list:XTREEFLAT_NODES[])=>{
|
||||
if(render==null||boxNodeinfo.value==null){
|
||||
console.error('render未初始化')
|
||||
return;
|
||||
}
|
||||
render.value!.setData(list)
|
||||
let dboxwidth = render.value!.getDrawBounds()
|
||||
|
||||
scrollTop_onlyRead.value = scrollTop.value
|
||||
scrollLeft_onlyRead.value = scrollLeft.value
|
||||
scrollTop.value = dboxwidth.y - (boxNodeinfo.value!.height!-dboxwidth.height)/2
|
||||
scrollLeft.value = dboxwidth.x - (boxNodeinfo.value!.width!-dboxwidth.width)/2
|
||||
setScrollPosition()
|
||||
|
||||
}
|
||||
|
||||
const onScrollTop = (evt:UniScrollEvent)=>{
|
||||
scrollTop_onlyRead.value = evt.detail.scrollTop
|
||||
}
|
||||
const onScrollLeft = (evt:UniScrollEvent)=>{
|
||||
scrollLeft_onlyRead.value = evt.detail.scrollLeft
|
||||
}
|
||||
const onScrollAll = (evt:UniScrollEvent)=>{
|
||||
nextTick(()=>{
|
||||
scrollTop_onlyRead.value = evt.detail.scrollTop
|
||||
scrollLeft_onlyRead.value = evt.detail.scrollLeft
|
||||
})
|
||||
}
|
||||
|
||||
const onclickCanvas = (evt:UniPointerEvent)=>{
|
||||
if(render.value==null||boxNodeinfo.value==null){
|
||||
return;
|
||||
}
|
||||
// #ifdef WEB
|
||||
let ele = xTreeFlatBox.value;
|
||||
if(ele==null) return;
|
||||
scrollTop_onlyRead.value = ele.scrollTop||0
|
||||
scrollLeft_onlyRead.value = ele.scrollLeft||0
|
||||
// #endif
|
||||
render.value!.addEventClick(evt,scrollTop_onlyRead.value,scrollLeft_onlyRead.value)
|
||||
}
|
||||
const onRenderClick = (item:XTREEFLAT_CHILDREN)=>{
|
||||
emits('click',item)
|
||||
}
|
||||
const onInit = ():Promise<any> =>{
|
||||
return new Promise(()=>{
|
||||
uni.createSelectorQuery()
|
||||
.in(proxy)
|
||||
.select(".xTreeFlatBox")
|
||||
.boundingClientRect()
|
||||
.exec(result=>{
|
||||
if(result.length == 0) return;
|
||||
let node = result[0]! as NodeInfo
|
||||
canvasWidth.value = Math.max(props.canvasWidth,node.width!)
|
||||
canvasHeight.value = Math.max(props.canvasHeight,node.height!)
|
||||
keyids.value +=10
|
||||
nextTick(()=>{
|
||||
uni.createCanvasContextAsync({
|
||||
id: id,
|
||||
component: proxy,
|
||||
success: (context : CanvasContext) => {
|
||||
let opts = {} as XTreeFlatOpts
|
||||
if(props.opts!=null){
|
||||
opts = props.opts! as XTreeFlatOpts
|
||||
}
|
||||
render.value = new OrgChartRenderer( {
|
||||
width:canvasWidth.value,
|
||||
height:canvasHeight.value,
|
||||
canvas:context,
|
||||
nodeInfo:node,
|
||||
bgColor:opts?.bgColor,
|
||||
nodeBgColor:opts?.nodeBgColor,
|
||||
fontColor:opts?.fontColor,
|
||||
fontSize: opts?.fontSize,
|
||||
lineColor:opts?.lineColor,
|
||||
lineWidth:opts?.lineWidth,
|
||||
padding: opts?.padding,
|
||||
gutter:opts?.gutter,
|
||||
parentLineGutter :opts?.parentLineGutter,
|
||||
enbleOpenChildren :opts?.enbleOpenChildren,
|
||||
nodeRadius : opts?.nodeRadius,
|
||||
layout: opts?.layout
|
||||
} )
|
||||
boxNodeinfo.value = node
|
||||
render.value!.onListen(onRenderClick)
|
||||
|
||||
if(props.list.length>0){
|
||||
setData(props.list)
|
||||
}
|
||||
/**
|
||||
* 引擎初始化完成,可以进行通过ref来异步设置数据
|
||||
*/
|
||||
emits('init')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
watch(():any=>props.opts,()=>{
|
||||
onInit()
|
||||
},{deep:true})
|
||||
onMounted(()=>{
|
||||
clearTimeout(tid)
|
||||
// #ifdef APP-ANDROID||APP-IOS||WEB
|
||||
tid = setTimeout(function() {
|
||||
onInit();
|
||||
}, 50);
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
tid = setTimeout(function() {
|
||||
onInit();
|
||||
}, 100);
|
||||
// #endif
|
||||
})
|
||||
|
||||
// #ifdef MP
|
||||
onReady(()=>{
|
||||
onInit();
|
||||
})
|
||||
// #endif
|
||||
|
||||
onBeforeUnmount(()=>{
|
||||
clearTimeout(tid)
|
||||
})
|
||||
defineExpose({
|
||||
/**
|
||||
* 设置树形组件的数据
|
||||
* @description 用于动态设置或更新树形组件的数据源
|
||||
* @param {XTREEFLAT_NODES[]} list - 树形节点数据数组
|
||||
* @returns {void}
|
||||
*/
|
||||
setData
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.xTreeFlatBox{
|
||||
position: relative;
|
||||
/* #ifdef WEB */
|
||||
overflow: auto;
|
||||
/* #endif */
|
||||
}
|
||||
/* #ifdef MP||WEB */
|
||||
.xTreeFlatBoxScroll{
|
||||
position: absolute;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user