mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-09 18:39:41 +02:00
fix HTML formatting in timeline diagrams
This commit is contained in:
@@ -225,4 +225,18 @@ timeline
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('13: should render markdown htmlLabels', () => {
|
||||||
|
const timelineCode = `%%{init: { 'logLevel': 'debug', 'theme': 'forest' } }%%
|
||||||
|
timeline
|
||||||
|
title Timeline of Industrial Revolution
|
||||||
|
section 17th-20th century
|
||||||
|
Industry 1.0 : Machinery, Water power, Steam <br>power
|
||||||
|
Industry 2.0 : Electricity, <strong>Internal combustion engine </strong>, Mass production
|
||||||
|
Industry 3.0 : Electronics, Computers, Automation
|
||||||
|
section 21st century
|
||||||
|
Industry 4.0 : Internet, Robotics, Internet of Things
|
||||||
|
Industry 5.0 : Artificial intelligence, Big data, 3D printing
|
||||||
|
`;
|
||||||
|
imgSnapshotTest(timelineCode);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,6 +1,90 @@
|
|||||||
import { arc as d3arc, select } from 'd3';
|
import { arc as d3arc, select } from 'd3';
|
||||||
|
import { createText } from '../../rendering-util/createText.js';
|
||||||
|
|
||||||
const MAX_SECTIONS = 12;
|
const MAX_SECTIONS = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process HTML content in node descriptions
|
||||||
|
* @param {object} textElem - The SVG element to append text to
|
||||||
|
* @param {object} node - The node object containing description and dimensions
|
||||||
|
* @param {object} conf - Configuration object
|
||||||
|
* @param {boolean} isVirtual - Whether this is for virtual height calculation
|
||||||
|
*/
|
||||||
|
const processHtmlContent = async function (textElem, node, conf, isVirtual = false) {
|
||||||
|
// Create temporary text to get initial dimensions
|
||||||
|
const tempText = textElem
|
||||||
|
.append('text')
|
||||||
|
.text(node.descr.replace(/<[^>]*>/g, ''))
|
||||||
|
.attr('dy', '1em')
|
||||||
|
.attr('alignment-baseline', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.call(wrap, node.width);
|
||||||
|
|
||||||
|
if (!isVirtual) {
|
||||||
|
tempText.attr('visibility', 'hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bbox = tempText.node().getBBox();
|
||||||
|
tempText.remove();
|
||||||
|
|
||||||
|
// Create the actual HTML content
|
||||||
|
const textObj = await createText(
|
||||||
|
textElem,
|
||||||
|
node.descr,
|
||||||
|
{
|
||||||
|
useHtmlLabels: true,
|
||||||
|
width: node.width,
|
||||||
|
classes: 'timeline-node-label',
|
||||||
|
isNode: true,
|
||||||
|
},
|
||||||
|
conf
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isVirtual) {
|
||||||
|
select(textObj).attr('transform', 'translate(0, 0)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the foreign object
|
||||||
|
const foreignObject = textElem.select('foreignObject');
|
||||||
|
if (foreignObject.node()) {
|
||||||
|
foreignObject.attr('width', `${10 * node.width}px`).attr('height', `${10 * node.width}px`);
|
||||||
|
|
||||||
|
const div = foreignObject.select('div');
|
||||||
|
if (div.node()) {
|
||||||
|
div
|
||||||
|
.style('display', 'table-cell')
|
||||||
|
.style('white-space', 'nowrap')
|
||||||
|
.style('line-height', '1.5')
|
||||||
|
.style('max-width', node.width + 'px')
|
||||||
|
.style('text-align', 'center');
|
||||||
|
|
||||||
|
let divBBox = div.node().getBoundingClientRect();
|
||||||
|
|
||||||
|
if (divBBox.width === node.width) {
|
||||||
|
div
|
||||||
|
.style('display', 'table')
|
||||||
|
.style('white-space', 'break-spaces')
|
||||||
|
.style('width', node.width + 'px');
|
||||||
|
|
||||||
|
divBBox = div.node().getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreignObject.attr('width', node.width).attr('height', divBBox.height);
|
||||||
|
|
||||||
|
if (!isVirtual) {
|
||||||
|
foreignObject.attr('x', -node.width / 2).attr('y', 3);
|
||||||
|
|
||||||
|
div.style('width', node.width + 'px');
|
||||||
|
} else {
|
||||||
|
bbox.height = divBBox.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bbox;
|
||||||
|
};
|
||||||
|
|
||||||
export const drawRect = function (elem, rectData) {
|
export const drawRect = function (elem, rectData) {
|
||||||
const rectElem = elem.append('rect');
|
const rectElem = elem.append('rect');
|
||||||
rectElem.attr('x', rectData.x);
|
rectElem.attr('x', rectData.x);
|
||||||
@@ -409,6 +493,9 @@ const _drawTextCandidateFunc = (function () {
|
|||||||
.style('display', 'table-cell')
|
.style('display', 'table-cell')
|
||||||
.style('text-align', 'center')
|
.style('text-align', 'center')
|
||||||
.style('vertical-align', 'middle')
|
.style('vertical-align', 'middle')
|
||||||
|
.style('word-wrap', 'break-word')
|
||||||
|
.style('overflow-wrap', 'break-word')
|
||||||
|
.style('white-space', 'normal')
|
||||||
.text(content);
|
.text(content);
|
||||||
|
|
||||||
byTspan(content, body, x, y, width, height, textAttrs, conf);
|
byTspan(content, body, x, y, width, height, textAttrs, conf);
|
||||||
@@ -493,7 +580,7 @@ function wrap(text, width) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const drawNode = function (elem, node, fullSection, conf) {
|
export const drawNode = async function (elem, node, fullSection, conf) {
|
||||||
const section = (fullSection % MAX_SECTIONS) - 1;
|
const section = (fullSection % MAX_SECTIONS) - 1;
|
||||||
const nodeElem = elem.append('g');
|
const nodeElem = elem.append('g');
|
||||||
node.section = section;
|
node.section = section;
|
||||||
@@ -506,19 +593,30 @@ export const drawNode = function (elem, node, fullSection, conf) {
|
|||||||
// Create the wrapped text element
|
// Create the wrapped text element
|
||||||
const textElem = nodeElem.append('g');
|
const textElem = nodeElem.append('g');
|
||||||
|
|
||||||
const txt = textElem
|
const hasHtml = /<[a-z][\S\s]*>/i.test(node.descr);
|
||||||
.append('text')
|
|
||||||
.text(node.descr)
|
if (hasHtml) {
|
||||||
.attr('dy', '1em')
|
const bbox = await processHtmlContent(textElem, node, conf, false);
|
||||||
.attr('alignment-baseline', 'middle')
|
|
||||||
.attr('dominant-baseline', 'middle')
|
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
|
||||||
.attr('text-anchor', 'middle')
|
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
|
||||||
.call(wrap, node.width);
|
node.height = Math.max(node.height, node.maxHeight);
|
||||||
const bbox = txt.node().getBBox();
|
node.width = node.width + 2 * node.padding;
|
||||||
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
|
} else {
|
||||||
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
|
const txt = textElem
|
||||||
node.height = Math.max(node.height, node.maxHeight);
|
.append('text')
|
||||||
node.width = node.width + 2 * node.padding;
|
.text(node.descr)
|
||||||
|
.attr('dy', '1em')
|
||||||
|
.attr('alignment-baseline', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.call(wrap, node.width);
|
||||||
|
const bbox = txt.node().getBBox();
|
||||||
|
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
|
||||||
|
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
|
||||||
|
node.height = Math.max(node.height, node.maxHeight);
|
||||||
|
node.width = node.width + 2 * node.padding;
|
||||||
|
}
|
||||||
|
|
||||||
textElem.attr('transform', 'translate(' + node.width / 2 + ', ' + node.padding / 2 + ')');
|
textElem.attr('transform', 'translate(' + node.width / 2 + ', ' + node.padding / 2 + ')');
|
||||||
|
|
||||||
@@ -528,17 +626,25 @@ export const drawNode = function (elem, node, fullSection, conf) {
|
|||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVirtualNodeHeight = function (elem, node, conf) {
|
export const getVirtualNodeHeight = async function (elem, node, conf) {
|
||||||
const textElem = elem.append('g');
|
const textElem = elem.append('g');
|
||||||
const txt = textElem
|
|
||||||
.append('text')
|
const hasHtml = /<[a-z][\S\s]*>/i.test(node.descr);
|
||||||
.text(node.descr)
|
|
||||||
.attr('dy', '1em')
|
let bbox;
|
||||||
.attr('alignment-baseline', 'middle')
|
if (hasHtml) {
|
||||||
.attr('dominant-baseline', 'middle')
|
bbox = await processHtmlContent(textElem, node, conf, true);
|
||||||
.attr('text-anchor', 'middle')
|
} else {
|
||||||
.call(wrap, node.width);
|
const txt = textElem
|
||||||
const bbox = txt.node().getBBox();
|
.append('text')
|
||||||
|
.text(node.descr)
|
||||||
|
.attr('dy', '1em')
|
||||||
|
.attr('alignment-baseline', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.call(wrap, node.width);
|
||||||
|
bbox = txt.node().getBBox();
|
||||||
|
}
|
||||||
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
|
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
|
||||||
textElem.remove();
|
textElem.remove();
|
||||||
return bbox.height + fontSize * 1.1 * 0.5 + node.padding;
|
return bbox.height + fontSize * 1.1 * 0.5 + node.padding;
|
||||||
|
@@ -25,7 +25,7 @@ interface TimelineTask {
|
|||||||
score: number;
|
score: number;
|
||||||
events: string[];
|
events: string[];
|
||||||
}
|
}
|
||||||
export const draw = function (text: string, id: string, version: string, diagObj: Diagram) {
|
export const draw = async function (text: string, id: string, version: string, diagObj: Diagram) {
|
||||||
//1. Fetch the configuration
|
//1. Fetch the configuration
|
||||||
const conf = getConfig();
|
const conf = getConfig();
|
||||||
// @ts-expect-error - wrong config?
|
// @ts-expect-error - wrong config?
|
||||||
@@ -77,7 +77,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
let hasSections = true;
|
let hasSections = true;
|
||||||
|
|
||||||
//Calculate the max height of the sections
|
//Calculate the max height of the sections
|
||||||
sections.forEach(function (section: string) {
|
for (const section of sections) {
|
||||||
const sectionNode: Block<string, number> = {
|
const sectionNode: Block<string, number> = {
|
||||||
number: sectionNumber,
|
number: sectionNumber,
|
||||||
descr: section,
|
descr: section,
|
||||||
@@ -86,10 +86,10 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
padding: 20,
|
padding: 20,
|
||||||
maxHeight: maxSectionHeight,
|
maxHeight: maxSectionHeight,
|
||||||
};
|
};
|
||||||
const sectionHeight = svgDraw.getVirtualNodeHeight(svg, sectionNode, conf);
|
const sectionHeight = await svgDraw.getVirtualNodeHeight(svg, sectionNode, conf);
|
||||||
log.debug('sectionHeight before draw', sectionHeight);
|
log.debug('sectionHeight before draw', sectionHeight);
|
||||||
maxSectionHeight = Math.max(maxSectionHeight, sectionHeight + 20);
|
maxSectionHeight = Math.max(maxSectionHeight, sectionHeight + 20);
|
||||||
});
|
}
|
||||||
|
|
||||||
//tasks length and maxEventCount
|
//tasks length and maxEventCount
|
||||||
let maxEventCount = 0;
|
let maxEventCount = 0;
|
||||||
@@ -107,7 +107,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
padding: 20,
|
padding: 20,
|
||||||
maxHeight: maxTaskHeight,
|
maxHeight: maxTaskHeight,
|
||||||
};
|
};
|
||||||
const taskHeight = svgDraw.getVirtualNodeHeight(svg, taskNode, conf);
|
const taskHeight = await svgDraw.getVirtualNodeHeight(svg, taskNode, conf);
|
||||||
log.debug('taskHeight before draw', taskHeight);
|
log.debug('taskHeight before draw', taskHeight);
|
||||||
maxTaskHeight = Math.max(maxTaskHeight, taskHeight + 20);
|
maxTaskHeight = Math.max(maxTaskHeight, taskHeight + 20);
|
||||||
|
|
||||||
@@ -124,11 +124,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
padding: 20,
|
padding: 20,
|
||||||
maxHeight: 50,
|
maxHeight: 50,
|
||||||
};
|
};
|
||||||
maxEventLineLengthTemp += svgDraw.getVirtualNodeHeight(svg, eventNode, conf);
|
maxEventLineLengthTemp += await svgDraw.getVirtualNodeHeight(svg, eventNode, conf);
|
||||||
}
|
|
||||||
// Add spacing between events (10px per event except the last one)
|
|
||||||
if (task.events.length > 0) {
|
|
||||||
maxEventLineLengthTemp += (task.events.length - 1) * 10;
|
|
||||||
}
|
}
|
||||||
maxEventLineLength = Math.max(maxEventLineLength, maxEventLineLengthTemp);
|
maxEventLineLength = Math.max(maxEventLineLength, maxEventLineLengthTemp);
|
||||||
}
|
}
|
||||||
@@ -137,7 +133,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
log.debug('maxTaskHeight before draw', maxTaskHeight);
|
log.debug('maxTaskHeight before draw', maxTaskHeight);
|
||||||
|
|
||||||
if (sections && sections.length > 0) {
|
if (sections && sections.length > 0) {
|
||||||
sections.forEach((section) => {
|
for (const section of sections) {
|
||||||
//filter task where tasks.section == section
|
//filter task where tasks.section == section
|
||||||
const tasksForSection = tasks.filter((task) => task.section === section);
|
const tasksForSection = tasks.filter((task) => task.section === section);
|
||||||
|
|
||||||
@@ -151,7 +147,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
};
|
};
|
||||||
log.debug('sectionNode', sectionNode);
|
log.debug('sectionNode', sectionNode);
|
||||||
const sectionNodeWrapper = svg.append('g');
|
const sectionNodeWrapper = svg.append('g');
|
||||||
const node = svgDraw.drawNode(sectionNodeWrapper, sectionNode, sectionNumber, conf);
|
const node = await svgDraw.drawNode(sectionNodeWrapper, sectionNode, sectionNumber, conf);
|
||||||
log.debug('sectionNode output', node);
|
log.debug('sectionNode output', node);
|
||||||
|
|
||||||
sectionNodeWrapper.attr('transform', `translate(${masterX}, ${sectionBeginY})`);
|
sectionNodeWrapper.attr('transform', `translate(${masterX}, ${sectionBeginY})`);
|
||||||
@@ -160,7 +156,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
|
|
||||||
//draw tasks for this section
|
//draw tasks for this section
|
||||||
if (tasksForSection.length > 0) {
|
if (tasksForSection.length > 0) {
|
||||||
drawTasks(
|
await drawTasks(
|
||||||
svg,
|
svg,
|
||||||
tasksForSection,
|
tasksForSection,
|
||||||
sectionNumber,
|
sectionNumber,
|
||||||
@@ -179,11 +175,11 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
|
|
||||||
masterY = sectionBeginY;
|
masterY = sectionBeginY;
|
||||||
sectionNumber++;
|
sectionNumber++;
|
||||||
});
|
}
|
||||||
} else {
|
} else {
|
||||||
//draw tasks
|
//draw tasks
|
||||||
hasSections = false;
|
hasSections = false;
|
||||||
drawTasks(
|
await drawTasks(
|
||||||
svg,
|
svg,
|
||||||
tasks,
|
tasks,
|
||||||
sectionNumber,
|
sectionNumber,
|
||||||
@@ -237,7 +233,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
|
|||||||
// addSVGAccessibilityFields(diagObj.db, diagram, id);
|
// addSVGAccessibilityFields(diagObj.db, diagram, id);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const drawTasks = function (
|
export const drawTasks = async function (
|
||||||
diagram: Selection<SVGElement, unknown, null, undefined>,
|
diagram: Selection<SVGElement, unknown, null, undefined>,
|
||||||
tasks: TimelineTask[],
|
tasks: TimelineTask[],
|
||||||
sectionColor: number,
|
sectionColor: number,
|
||||||
@@ -266,7 +262,7 @@ export const drawTasks = function (
|
|||||||
// create task wrapper
|
// create task wrapper
|
||||||
|
|
||||||
const taskWrapper = diagram.append('g').attr('class', 'taskWrapper');
|
const taskWrapper = diagram.append('g').attr('class', 'taskWrapper');
|
||||||
const node = svgDraw.drawNode(taskWrapper, taskNode, sectionColor, conf);
|
const node = await svgDraw.drawNode(taskWrapper, taskNode, sectionColor, conf);
|
||||||
const taskHeight = node.height;
|
const taskHeight = node.height;
|
||||||
//log task height
|
//log task height
|
||||||
log.debug('taskHeight after draw', taskHeight);
|
log.debug('taskHeight after draw', taskHeight);
|
||||||
@@ -283,15 +279,22 @@ export const drawTasks = function (
|
|||||||
//add margin to task
|
//add margin to task
|
||||||
masterY += 100;
|
masterY += 100;
|
||||||
lineLength =
|
lineLength =
|
||||||
lineLength + drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf);
|
lineLength + (await drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf));
|
||||||
masterY -= 100;
|
masterY -= 100;
|
||||||
|
|
||||||
lineWrapper
|
lineWrapper
|
||||||
.append('line')
|
.append('line')
|
||||||
.attr('x1', masterX + 190 / 2)
|
.attr('x1', masterX + 190 / 2)
|
||||||
.attr('y1', masterY + maxTaskHeight) // Start from bottom of task box
|
.attr('y1', masterY + maxTaskHeight) // One section head + one task + margins
|
||||||
.attr('x2', masterX + 190 / 2) // Same x coordinate for vertical line
|
.attr('x2', masterX + 190 / 2) // Subtract stroke width so arrow point is retained
|
||||||
.attr('y2', masterY + maxTaskHeight + 100 + maxEventLineLength + 100) // End at consistent depth with ample padding for visible dashed lines and arrowheads
|
.attr(
|
||||||
|
'y2',
|
||||||
|
masterY +
|
||||||
|
maxTaskHeight +
|
||||||
|
(isWithoutSections ? maxTaskHeight : maxSectionHeight) +
|
||||||
|
maxEventLineLength +
|
||||||
|
120
|
||||||
|
)
|
||||||
.attr('stroke-width', 2)
|
.attr('stroke-width', 2)
|
||||||
.attr('stroke', 'black')
|
.attr('stroke', 'black')
|
||||||
.attr('marker-end', 'url(#arrowhead)')
|
.attr('marker-end', 'url(#arrowhead)')
|
||||||
@@ -308,7 +311,7 @@ export const drawTasks = function (
|
|||||||
masterY = masterY - 10;
|
masterY = masterY - 10;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const drawEvents = function (
|
export const drawEvents = async function (
|
||||||
diagram: Selection<SVGElement, unknown, null, undefined>,
|
diagram: Selection<SVGElement, unknown, null, undefined>,
|
||||||
events: string[],
|
events: string[],
|
||||||
sectionColor: number,
|
sectionColor: number,
|
||||||
@@ -335,7 +338,7 @@ export const drawEvents = function (
|
|||||||
log.debug('eventNode', eventNode);
|
log.debug('eventNode', eventNode);
|
||||||
// create event wrapper
|
// create event wrapper
|
||||||
const eventWrapper = diagram.append('g').attr('class', 'eventWrapper');
|
const eventWrapper = diagram.append('g').attr('class', 'eventWrapper');
|
||||||
const node = svgDraw.drawNode(eventWrapper, eventNode, sectionColor, conf);
|
const node = await svgDraw.drawNode(eventWrapper, eventNode, sectionColor, conf);
|
||||||
const eventHeight = node.height;
|
const eventHeight = node.height;
|
||||||
maxEventHeight = maxEventHeight + eventHeight;
|
maxEventHeight = maxEventHeight + eventHeight;
|
||||||
eventWrapper.attr('transform', `translate(${masterX}, ${masterY})`);
|
eventWrapper.attr('transform', `translate(${masterX}, ${masterY})`);
|
||||||
|
Reference in New Issue
Block a user