diff --git a/cypress/integration/rendering/timeline.spec.ts b/cypress/integration/rendering/timeline.spec.ts index 3785f5fcc..cb3ad1429 100644 --- a/cypress/integration/rendering/timeline.spec.ts +++ b/cypress/integration/rendering/timeline.spec.ts @@ -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
power + Industry 2.0 : Electricity, Internal combustion engine , 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); + }); }); diff --git a/packages/mermaid/src/diagrams/timeline/svgDraw.js b/packages/mermaid/src/diagrams/timeline/svgDraw.js index 723dc46f1..ed1a7f400 100644 --- a/packages/mermaid/src/diagrams/timeline/svgDraw.js +++ b/packages/mermaid/src/diagrams/timeline/svgDraw.js @@ -1,6 +1,90 @@ import { arc as d3arc, select } from 'd3'; +import { createText } from '../../rendering-util/createText.js'; + 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) { const rectElem = elem.append('rect'); rectElem.attr('x', rectData.x); @@ -409,6 +493,9 @@ const _drawTextCandidateFunc = (function () { .style('display', 'table-cell') .style('text-align', 'center') .style('vertical-align', 'middle') + .style('word-wrap', 'break-word') + .style('overflow-wrap', 'break-word') + .style('white-space', 'normal') .text(content); 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 nodeElem = elem.append('g'); node.section = section; @@ -506,19 +593,30 @@ export const drawNode = function (elem, node, fullSection, conf) { // Create the wrapped text element const textElem = nodeElem.append('g'); - const txt = textElem - .append('text') - .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; + const hasHtml = /<[a-z][\S\s]*>/i.test(node.descr); + + if (hasHtml) { + const bbox = await processHtmlContent(textElem, node, conf, false); + + 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; + } else { + const txt = textElem + .append('text') + .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 + ')'); @@ -528,17 +626,25 @@ export const drawNode = function (elem, node, fullSection, conf) { return node; }; -export const getVirtualNodeHeight = function (elem, node, conf) { +export const getVirtualNodeHeight = async function (elem, node, conf) { const textElem = elem.append('g'); - const txt = textElem - .append('text') - .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 hasHtml = /<[a-z][\S\s]*>/i.test(node.descr); + + let bbox; + if (hasHtml) { + bbox = await processHtmlContent(textElem, node, conf, true); + } else { + const txt = textElem + .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; textElem.remove(); return bbox.height + fontSize * 1.1 * 0.5 + node.padding; diff --git a/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts b/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts index 7f406b589..fec244395 100644 --- a/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts +++ b/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts @@ -25,7 +25,7 @@ interface TimelineTask { score: number; 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 const conf = getConfig(); // @ts-expect-error - wrong config? @@ -77,7 +77,7 @@ export const draw = function (text: string, id: string, version: string, diagObj let hasSections = true; //Calculate the max height of the sections - sections.forEach(function (section: string) { + for (const section of sections) { const sectionNode: Block = { number: sectionNumber, descr: section, @@ -86,10 +86,10 @@ export const draw = function (text: string, id: string, version: string, diagObj padding: 20, maxHeight: maxSectionHeight, }; - const sectionHeight = svgDraw.getVirtualNodeHeight(svg, sectionNode, conf); + const sectionHeight = await svgDraw.getVirtualNodeHeight(svg, sectionNode, conf); log.debug('sectionHeight before draw', sectionHeight); maxSectionHeight = Math.max(maxSectionHeight, sectionHeight + 20); - }); + } //tasks length and maxEventCount let maxEventCount = 0; @@ -107,7 +107,7 @@ export const draw = function (text: string, id: string, version: string, diagObj padding: 20, maxHeight: maxTaskHeight, }; - const taskHeight = svgDraw.getVirtualNodeHeight(svg, taskNode, conf); + const taskHeight = await svgDraw.getVirtualNodeHeight(svg, taskNode, conf); log.debug('taskHeight before draw', taskHeight); maxTaskHeight = Math.max(maxTaskHeight, taskHeight + 20); @@ -124,11 +124,7 @@ export const draw = function (text: string, id: string, version: string, diagObj padding: 20, maxHeight: 50, }; - maxEventLineLengthTemp += 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; + maxEventLineLengthTemp += await svgDraw.getVirtualNodeHeight(svg, eventNode, conf); } 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); if (sections && sections.length > 0) { - sections.forEach((section) => { + for (const section of sections) { //filter task where tasks.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); 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); 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 if (tasksForSection.length > 0) { - drawTasks( + await drawTasks( svg, tasksForSection, sectionNumber, @@ -179,11 +175,11 @@ export const draw = function (text: string, id: string, version: string, diagObj masterY = sectionBeginY; sectionNumber++; - }); + } } else { //draw tasks hasSections = false; - drawTasks( + await drawTasks( svg, tasks, sectionNumber, @@ -237,7 +233,7 @@ export const draw = function (text: string, id: string, version: string, diagObj // addSVGAccessibilityFields(diagObj.db, diagram, id); }; -export const drawTasks = function ( +export const drawTasks = async function ( diagram: Selection, tasks: TimelineTask[], sectionColor: number, @@ -266,7 +262,7 @@ export const drawTasks = function ( // create task wrapper 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; //log task height log.debug('taskHeight after draw', taskHeight); @@ -283,15 +279,22 @@ export const drawTasks = function ( //add margin to task masterY += 100; lineLength = - lineLength + drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf); + lineLength + (await drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf)); masterY -= 100; lineWrapper .append('line') .attr('x1', masterX + 190 / 2) - .attr('y1', masterY + maxTaskHeight) // Start from bottom of task box - .attr('x2', masterX + 190 / 2) // Same x coordinate for vertical line - .attr('y2', masterY + maxTaskHeight + 100 + maxEventLineLength + 100) // End at consistent depth with ample padding for visible dashed lines and arrowheads + .attr('y1', masterY + maxTaskHeight) // One section head + one task + margins + .attr('x2', masterX + 190 / 2) // Subtract stroke width so arrow point is retained + .attr( + 'y2', + masterY + + maxTaskHeight + + (isWithoutSections ? maxTaskHeight : maxSectionHeight) + + maxEventLineLength + + 120 + ) .attr('stroke-width', 2) .attr('stroke', 'black') .attr('marker-end', 'url(#arrowhead)') @@ -308,7 +311,7 @@ export const drawTasks = function ( masterY = masterY - 10; }; -export const drawEvents = function ( +export const drawEvents = async function ( diagram: Selection, events: string[], sectionColor: number, @@ -335,7 +338,7 @@ export const drawEvents = function ( log.debug('eventNode', eventNode); // create event wrapper 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; maxEventHeight = maxEventHeight + eventHeight; eventWrapper.attr('transform', `translate(${masterX}, ${masterY})`);