mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-13 12:29:42 +02:00
Add classBox shape
This commit is contained in:
@@ -1,125 +1,322 @@
|
||||
import { getNodeClasses, updateNodeBounds } from './util.js';
|
||||
import { getConfig } from '$root/diagram-api/diagramAPI.js';
|
||||
import { getConfig } from '../../../diagram-api/diagramAPI.js';
|
||||
import { createText } from '../../createText.js';
|
||||
import { select } from 'd3';
|
||||
import type { Node } from '$root/rendering-util/types.d.ts';
|
||||
import { evaluate } from '$root/diagrams/common/common.js';
|
||||
import { calculateTextDimensions, calculateTextWidth } from '$root/utils.js';
|
||||
import type { Node } from '../../types.js';
|
||||
import { calculateTextHeight, calculateTextWidth } from '../../../utils.js';
|
||||
import type { ClassMember, ClassNode } from '../../../diagrams/class/classTypes.js';
|
||||
import rough from 'roughjs';
|
||||
import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
|
||||
import intersect from '../intersect/index.js';
|
||||
|
||||
const config = getConfig();
|
||||
const PADDING = config.class!.padding ?? 10;
|
||||
const GAP = PADDING;
|
||||
|
||||
export const classBox = async (parent: SVGAElement, node: Node): Promise<SVGAElement> => {
|
||||
const styles = node.styles.join(';');
|
||||
const useHtmlLabels = config.class?.htmlLabels ?? config.htmlLabels ?? true;
|
||||
|
||||
const mainGroup = parent
|
||||
const { labelStyles, nodeStyles } = styles2String(node);
|
||||
node.labelStyle = labelStyles;
|
||||
const { cssStyles } = node;
|
||||
|
||||
const classNode = node as unknown as ClassNode;
|
||||
node.cssStyles = classNode.styles;
|
||||
if (!classNode.annotations) {
|
||||
classNode.annotations = [];
|
||||
}
|
||||
if (!classNode.members) {
|
||||
classNode.members = [];
|
||||
}
|
||||
if (!classNode.methods) {
|
||||
classNode.methods = [];
|
||||
}
|
||||
|
||||
const styles = classNode.styles ? classNode.styles.join(';') : '';
|
||||
|
||||
const { shapeSvg, bbox } = await textHelper(parent, node);
|
||||
|
||||
const renderExtraBox =
|
||||
classNode.members.length === 0 &&
|
||||
classNode.methods.length === 0 &&
|
||||
!config.class?.hideEmptyMembersBox;
|
||||
|
||||
// @ts-ignore TODO: Fix rough typings
|
||||
const rc = rough.svg(shapeSvg);
|
||||
const options = userNodeOverrides(node, {});
|
||||
|
||||
if (node.look !== 'handDrawn') {
|
||||
options.roughness = 0;
|
||||
options.fillStyle = 'solid';
|
||||
}
|
||||
|
||||
const w = bbox.width;
|
||||
const h = bbox.height;
|
||||
const x = -w / 2;
|
||||
const y = -h / 2;
|
||||
|
||||
const roughRect = rc.rectangle(
|
||||
x - PADDING,
|
||||
y - PADDING - (renderExtraBox ? PADDING : 0),
|
||||
w + 2 * PADDING,
|
||||
h + 2 * PADDING + (renderExtraBox ? PADDING * 2 : 0),
|
||||
options
|
||||
);
|
||||
|
||||
const rect = shapeSvg.insert(() => roughRect, ':first-child');
|
||||
rect
|
||||
.attr('class', 'basic label-container')
|
||||
.attr('style', styles)
|
||||
.attr('style', cssStyles)
|
||||
.attr('style', nodeStyles);
|
||||
const rectBBox = rect.node().getBBox();
|
||||
|
||||
// Rect is centered so now adjust labels.
|
||||
// TODO: Fix types
|
||||
shapeSvg.selectAll('.text').each((_: any, i: number, nodes: any) => {
|
||||
const text = select<any, unknown>(nodes[i]);
|
||||
|
||||
// Get the current transform attribute
|
||||
const transform = text.attr('transform');
|
||||
|
||||
// Initialize variables for the translation values
|
||||
let translateY = 0;
|
||||
|
||||
// Check if the transform attribute exists
|
||||
if (transform) {
|
||||
// Extract the translate values using a regex
|
||||
const regex = RegExp(/translate\(([^,]+),([^)]+)\)/);
|
||||
const translate = regex.exec(transform);
|
||||
|
||||
if (translate) {
|
||||
translateY = parseFloat(translate[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to the y value
|
||||
const newTranslateY = translateY + y + PADDING - (renderExtraBox ? PADDING : 0);
|
||||
let newTranslateX = x;
|
||||
|
||||
if (text.attr('class').includes('label') || text.attr('class').includes('annotation')) {
|
||||
newTranslateX = -text.node()?.getBBox().width / 2 || 0;
|
||||
}
|
||||
|
||||
// Set the updated transform attribute
|
||||
text.attr('transform', `translate(${newTranslateX},${newTranslateY})`);
|
||||
});
|
||||
|
||||
// Render divider lines.
|
||||
const annotationGroupHeight =
|
||||
shapeSvg.select('.annotation-group').node().getBBox().height -
|
||||
(renderExtraBox ? PADDING / 2 : 0) || 0;
|
||||
const labelGroupHeight =
|
||||
shapeSvg.select('.label-group').node().getBBox().height - (renderExtraBox ? PADDING / 2 : 0) ||
|
||||
0;
|
||||
const membersGroupHeight =
|
||||
shapeSvg.select('.members-group').node().getBBox().height -
|
||||
(renderExtraBox ? PADDING / 2 : 0) || 0;
|
||||
if (
|
||||
classNode.label &&
|
||||
(classNode.members.length > 0 || classNode.methods.length > 0 || renderExtraBox)
|
||||
) {
|
||||
const roughLine = rc.line(
|
||||
rectBBox.x,
|
||||
annotationGroupHeight + labelGroupHeight + y + PADDING,
|
||||
rectBBox.x + rectBBox.width,
|
||||
annotationGroupHeight + labelGroupHeight + y + PADDING,
|
||||
options
|
||||
);
|
||||
const line = shapeSvg.insert(() => roughLine);
|
||||
line
|
||||
.attr('class', 'divider', 'style', styles)
|
||||
.attr('style', cssStyles)
|
||||
.attr('style', nodeStyles);
|
||||
}
|
||||
|
||||
if (classNode.members.length > 0 && classNode.methods.length > 0) {
|
||||
const roughLine = rc.line(
|
||||
rectBBox.x,
|
||||
annotationGroupHeight + labelGroupHeight + membersGroupHeight + y + GAP * 2 + PADDING,
|
||||
rectBBox.x + rectBBox.width,
|
||||
annotationGroupHeight + labelGroupHeight + membersGroupHeight + y + PADDING + GAP * 2,
|
||||
options
|
||||
);
|
||||
const line = shapeSvg.insert(() => roughLine);
|
||||
line
|
||||
.attr('class', 'divider', 'style', styles)
|
||||
.attr('style', cssStyles)
|
||||
.attr('style', nodeStyles);
|
||||
}
|
||||
|
||||
if (classNode.look !== 'handDrawn') {
|
||||
if (cssStyles) {
|
||||
shapeSvg.selectAll('path').attr('style', cssStyles);
|
||||
}
|
||||
if (nodeStyles) {
|
||||
shapeSvg.selectAll('path').attr('style', nodeStyles);
|
||||
}
|
||||
if (styles) {
|
||||
shapeSvg.selectAll('path').attr('style', styles);
|
||||
}
|
||||
}
|
||||
|
||||
// // Ignore background stroke
|
||||
shapeSvg.select(':nth-child(2)').attr('style', styles);
|
||||
|
||||
// Divider lines
|
||||
shapeSvg.selectAll('.divider').select('path').attr('style', styles);
|
||||
|
||||
// Text elements
|
||||
shapeSvg.selectAll('span').attr('style', styles);
|
||||
// SVG text uses fill, stroke, and stroke-width
|
||||
if (!useHtmlLabels) {
|
||||
// We just want to apply color to the text
|
||||
const colorRegex = RegExp(/color\s*:\s*([^;]*)/);
|
||||
|
||||
const match = colorRegex.exec(styles);
|
||||
if (match) {
|
||||
const colorStyle = match[0].replace('color', 'fill');
|
||||
shapeSvg.selectAll('tspan').attr('style', colorStyle);
|
||||
}
|
||||
}
|
||||
|
||||
updateNodeBounds(node, rect);
|
||||
node.intersect = function (point) {
|
||||
return intersect.rect(node, point);
|
||||
};
|
||||
|
||||
return shapeSvg;
|
||||
};
|
||||
|
||||
const textHelper = async (parent: SVGAElement, node: any) => {
|
||||
const shapeSvg = parent
|
||||
// @ts-ignore: Ignore error for using .insert on SVGAElement
|
||||
.insert('g')
|
||||
.attr('class', getNodeClasses(node)) // 'styleClass'
|
||||
.attr('id', node.domId ?? node.id);
|
||||
.attr('class', getNodeClasses(node))
|
||||
.attr('id', node.domId || node.id);
|
||||
|
||||
const TEXT_PADDING = 6;
|
||||
|
||||
let annotationGroup = null;
|
||||
let labelGroup = null;
|
||||
let membersGroup = null;
|
||||
let methodsGroup = null;
|
||||
|
||||
let annotationGroupHeight = 0;
|
||||
let labelGroupHeight = 0;
|
||||
let membersGroupHeight = 0;
|
||||
|
||||
const config = getConfig();
|
||||
annotationGroup = shapeSvg.insert('g').attr('class', 'annotation-group text');
|
||||
if (node.annotations && node.annotations.length > 0) {
|
||||
const annotation = node.annotations[0];
|
||||
await addText(annotationGroup, { text: `«${annotation}»` } as unknown as ClassMember, 0);
|
||||
|
||||
const PADDING = config.class!.padding;
|
||||
const GAP = PADDING;
|
||||
|
||||
if (node.label) {
|
||||
labelGroup = mainGroup.insert('g').attr('class', 'label-group');
|
||||
await helper(labelGroup, node, 0);
|
||||
const labelGroupBBox = labelGroup.node().getBBox();
|
||||
labelGroupHeight = labelGroupBBox.height;
|
||||
const annotationGroupBBox = annotationGroup.node().getBBox();
|
||||
annotationGroupHeight = annotationGroupBBox.height;
|
||||
}
|
||||
|
||||
labelGroup = shapeSvg.insert('g').attr('class', 'label-group text');
|
||||
await addText(labelGroup, node, 0, ['font-weight: bolder;']);
|
||||
const labelGroupBBox = labelGroup.node().getBBox();
|
||||
labelGroupHeight = labelGroupBBox.height;
|
||||
|
||||
membersGroup = shapeSvg.insert('g').attr('class', 'members-group text');
|
||||
let yOffset = 0;
|
||||
if (node.members) {
|
||||
membersGroup = mainGroup.insert('g').attr('class', 'members-group');
|
||||
let yOffset = 0;
|
||||
for (const member of node.members) {
|
||||
await helper(membersGroup, member, yOffset);
|
||||
yOffset += calculateTextDimensions(member.text, config).height;
|
||||
await addText(membersGroup, member, yOffset, [member.parseClassifier()]);
|
||||
yOffset += calculateTextHeight(member.text, config) + TEXT_PADDING;
|
||||
}
|
||||
membersGroupHeight = membersGroup.node().getBBox().height;
|
||||
membersGroup.attr('transform', `translate(0, ${labelGroupHeight + GAP * 3})`);
|
||||
}
|
||||
|
||||
if (node.methods) {
|
||||
methodsGroup = mainGroup.insert('g').attr('class', 'methods-group');
|
||||
let methodsYOffset = 0;
|
||||
for (const method of node.methods) {
|
||||
await helper(methodsGroup, method, methodsYOffset);
|
||||
methodsYOffset += calculateTextDimensions(method.text, config).height;
|
||||
}
|
||||
|
||||
methodsGroup.attr(
|
||||
'transform',
|
||||
`translate(0, ${labelGroupHeight + (membersGroupHeight ? membersGroupHeight + GAP * 5 : GAP * 3)})`
|
||||
);
|
||||
methodsGroup = shapeSvg.insert('g').attr('class', 'methods-group text');
|
||||
let methodsYOffset = 0;
|
||||
for (const method of node.methods) {
|
||||
await addText(methodsGroup, method, methodsYOffset, [method.parseClassifier()]);
|
||||
methodsYOffset += calculateTextHeight(method.text, config) + TEXT_PADDING;
|
||||
}
|
||||
|
||||
const mainGroupBBox = mainGroup.node().getBBox();
|
||||
const labelGroupBBox = labelGroup.node().getBBox();
|
||||
// Center label
|
||||
labelGroup.attr(
|
||||
let bbox = shapeSvg.node().getBBox();
|
||||
|
||||
// Center annotation
|
||||
if (annotationGroup !== null) {
|
||||
const annotationGroupBBox = annotationGroup.node().getBBox();
|
||||
annotationGroup.attr('transform', `translate(${-annotationGroupBBox.width / 2})`);
|
||||
}
|
||||
|
||||
// Adjust label
|
||||
labelGroup.attr('transform', `translate(${-labelGroupBBox.width / 2}, ${annotationGroupHeight})`);
|
||||
|
||||
bbox = shapeSvg.node().getBBox();
|
||||
|
||||
membersGroup.attr(
|
||||
'transform',
|
||||
`translate(${mainGroupBBox.width / 2 - labelGroupBBox.width / 2}, 0)`
|
||||
`translate(${0}, ${annotationGroupHeight + labelGroupHeight + GAP * 2})`
|
||||
);
|
||||
bbox = shapeSvg.node().getBBox();
|
||||
methodsGroup.attr(
|
||||
'transform',
|
||||
`translate(${0}, ${annotationGroupHeight + labelGroupHeight + (membersGroupHeight ? membersGroupHeight + GAP * 4 : GAP * 2)})`
|
||||
);
|
||||
|
||||
// Insert the rectangle around the main group
|
||||
mainGroup
|
||||
.insert('rect', ':first-child')
|
||||
.attr('style', styles)
|
||||
.attr('data-id', 'abc')
|
||||
.attr('data-et', 'node')
|
||||
.attr('x', mainGroupBBox.x - PADDING)
|
||||
.attr('y', mainGroupBBox.y - PADDING)
|
||||
.attr('width', mainGroupBBox.width + 2 * PADDING)
|
||||
.attr('height', mainGroupBBox.height + 2 * PADDING);
|
||||
bbox = shapeSvg.node().getBBox();
|
||||
|
||||
// Render separating lines.
|
||||
if (node.label && (node.members.length > 0 || node.methods.length > 0)) {
|
||||
mainGroup
|
||||
.insert('line')
|
||||
.attr('x1', 0 - PADDING)
|
||||
.attr('y1', labelGroupHeight - GAP)
|
||||
.attr('x2', mainGroupBBox.width + PADDING)
|
||||
.attr('y2', labelGroupHeight - GAP)
|
||||
.attr('class', 'divider');
|
||||
}
|
||||
|
||||
if (node.members.length > 0 && node.methods.length > 0) {
|
||||
mainGroup
|
||||
.insert('line')
|
||||
.attr('x1', 0 - PADDING)
|
||||
.attr('y1', labelGroupHeight + membersGroupHeight + GAP * 2)
|
||||
.attr('x2', mainGroupBBox.width + PADDING)
|
||||
.attr('y2', labelGroupHeight + membersGroupHeight + GAP * 2)
|
||||
.attr('class', 'divider');
|
||||
}
|
||||
|
||||
updateNodeBounds(node, mainGroup);
|
||||
return mainGroup;
|
||||
return { shapeSvg, bbox };
|
||||
};
|
||||
|
||||
const helper = async (parentGroup, node, yOffset) => {
|
||||
const textEl = parentGroup.insert('g').attr('class', 'label').attr('style', node.labelStyle);
|
||||
const textContent = node.text;
|
||||
const addText = async (
|
||||
parentGroup: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||
node: Node | ClassNode | ClassMember,
|
||||
yOffset: number,
|
||||
styles: string[] = []
|
||||
) => {
|
||||
const textEl = parentGroup.insert('g').attr('class', 'label').attr('style', styles);
|
||||
const config = getConfig();
|
||||
const useHtmlLabels = config.class?.htmlLabels ?? config.htmlLabels ?? true;
|
||||
|
||||
let textContent = '';
|
||||
if ('text' in node) {
|
||||
textContent = node.text;
|
||||
} else {
|
||||
textContent = node.label!;
|
||||
}
|
||||
|
||||
const text = await createText(
|
||||
textEl,
|
||||
textContent,
|
||||
{
|
||||
width: calculateTextWidth(textContent, config),
|
||||
width: calculateTextWidth(textContent, config) + 50, // Add room for error when splitting text into multiple lines
|
||||
classes: 'markdown-node-label',
|
||||
style: node.labelStyle,
|
||||
useHtmlLabels: true,
|
||||
useHtmlLabels,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
let bbox = text.getBBox();
|
||||
let bbox;
|
||||
|
||||
if (evaluate(config.flowchart.htmlLabels)) {
|
||||
// createText() creates unwanted behavior because of syntax, so fix
|
||||
if (!useHtmlLabels) {
|
||||
text.children[0].textContent = text.textContent.replaceAll('>', '>').replaceAll('<', '<');
|
||||
if (text.children[0].textContent === '') {
|
||||
const preserveSpace = textContent[1] === ' ';
|
||||
// Text was improperly removed due to spaces (preserve one space if present)
|
||||
text.children[0].textContent =
|
||||
textContent[0] +
|
||||
textContent.substring(1).replaceAll('>', '>').replaceAll('<', '<').trim();
|
||||
if (preserveSpace) {
|
||||
text.children[0].textContent =
|
||||
text.children[0].textContent[0] + ' ' + text.children[0].textContent.substring(1);
|
||||
}
|
||||
}
|
||||
// Get rid of extra multi-line text elements
|
||||
if (text.children.length > 1) {
|
||||
text.removeChild(text.children[1]);
|
||||
}
|
||||
|
||||
// Get the bounding box after the text update
|
||||
bbox = text.getBBox();
|
||||
} else {
|
||||
const div = text.children[0];
|
||||
const dv = select(text);
|
||||
|
||||
@@ -128,8 +325,5 @@ const helper = async (parentGroup, node, yOffset) => {
|
||||
dv.attr('height', bbox.height);
|
||||
}
|
||||
|
||||
textEl.attr('transform', 'translate(' + 0 + ', ' + (-bbox.height / 2 + yOffset) + ')');
|
||||
if (node.centerLabel) {
|
||||
textEl.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')');
|
||||
}
|
||||
textEl.attr('transform', 'translate(0,' + (-bbox.height / 2 + yOffset) + ')');
|
||||
};
|
||||
|
Reference in New Issue
Block a user