Compare commits

...

8 Commits

Author SHA1 Message Date
darshanr0107
d2c5cbd408 Merge branch 'develop' into 4743-timeline-html-formatting 2025-07-11 17:36:16 +05:30
darshanr0107
20467bcbe6 resolve visual regression issues and fix broken diagram in PR 2025-07-11 13:53:17 +05:30
darshanr0107
dd213fe86c Merge branch 'develop' into 4743-timeline-html-formatting 2025-07-01 12:34:09 +05:30
darshanr0107
800f23fc01 Merge branch 'develop' into 4743-timeline-html-formatting 2025-06-25 13:09:25 +05:30
darshanr0107
67aa1a4dc1 Merge branch 'develop' into 4743-timeline-html-formatting 2025-06-20 11:50:35 +05:30
darshanr0107
c1bcdcfbad added changeset 2025-06-20 11:49:37 +05:30
darshanr0107
f528e2daa4 fix multicharacter sanitization 2025-06-19 12:59:51 +05:30
darshanr0107
a867842f32 fix HTML formatting in timeline diagrams 2025-06-19 12:05:22 +05:30
4 changed files with 164 additions and 41 deletions

View File

@@ -0,0 +1,5 @@
---
'mermaid': major
---
Currently, HTML tags such as <em>, <strong>, <sup>, <a>, <ul>, and <li> are supported in Flowchart and Class diagram labels but not in Timeline diagrams. This change introduces support for basic HTML formatting in Timeline labels, enabling richer text formatting and better usability for multi-line content like descriptions, footnotes, and styled annotations

View File

@@ -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);
});
});

View File

@@ -1,6 +1,91 @@
import { arc as d3arc, select } from 'd3';
import { createText } from '../../rendering-util/createText.js';
import DOMPurify from 'dompurify';
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 sanitizedHtml = DOMPurify.sanitize(node.descr, { ALLOWED_TAGS: [] });
const tempText = textElem
.append('text')
.text(sanitizedHtml)
.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');
}
bbox.height = divBBox.height;
}
}
return bbox;
};
export const drawRect = function (elem, rectData) {
const rectElem = elem.append('rect');
rectElem.attr('x', rectData.x);
@@ -409,6 +494,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 +581,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 +594,28 @@ 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);
node.height = bbox.height + 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 +625,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;

View File

@@ -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();
const LEFT_MARGIN = conf.timeline?.leftMargin ?? 50;
@@ -76,7 +76,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<string, number> = {
number: sectionNumber,
descr: section,
@@ -85,10 +85,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;
@@ -106,7 +106,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);
@@ -123,9 +123,8 @@ export const draw = function (text: string, id: string, version: string, diagObj
padding: 20,
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;
}
@@ -136,7 +135,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);
@@ -150,7 +149,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})`);
@@ -159,7 +158,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,
@@ -178,11 +177,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,
@@ -236,7 +235,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<SVGElement, unknown, null, undefined>,
tasks: TimelineTask[],
sectionColor: number,
@@ -265,7 +264,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);
@@ -282,7 +281,7 @@ 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
@@ -307,7 +306,7 @@ export const drawTasks = function (
masterY = masterY - 10;
};
export const drawEvents = function (
export const drawEvents = async function (
diagram: Selection<SVGElement, unknown, null, undefined>,
events: string[],
sectionColor: number,
@@ -334,7 +333,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})`);