Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/state-tooltip-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix: render state diagram click tooltips with mermaidTooltip
47 changes: 47 additions & 0 deletions cypress/integration/rendering/state/stateDiagram-v2.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ describe('State diagram', () => {
{ logLevel: 0, fontFamily: 'courier' }
);
});
it('v2 should render click directive tooltips on linked states', () => {
renderGraph(
`
stateDiagram-v2
A: Google
click A "https://google.com" "Visit Google"
`,
{ securityLevel: 'loose', screenshot: false }
);

cy.get('svg a').should(($links) => {
const clickableLink = $links
.toArray()
.find((link) => link.getAttribute('xlink:href') === 'https://google.com');

expect(clickableLink, 'clickable state link').to.not.equal(undefined);
expect(clickableLink?.querySelector('g.node[title="Visit Google"]')).to.not.equal(null);
});
});
it('v2 should render a long descriptions instead of id when available', () => {
imgSnapshotTest(
`
Expand Down Expand Up @@ -506,6 +525,34 @@ stateDiagram-v2
});
});

for (const { look, nodeSelector } of [
{ look: 'classic', nodeSelector: 'g.node' },
{ look: 'handDrawn', nodeSelector: 'g.rough-node' },
]) {
it(`v2 should render clickable state nodes with a tooltip title for ${look} look`, () => {
renderGraph(
`
stateDiagram-v2
A: Google
click A "https://google.com" "Visit Google"
`,
{ look, securityLevel: 'loose', screenshot: false }
);

cy.get('svg a').should(($links) => {
const clickableLink = $links
.toArray()
.find((link) => link.getAttribute('xlink:href') === 'https://google.com');
expect(clickableLink, 'clickable state link').to.not.equal(undefined);
expect(clickableLink?.getAttribute('title')).to.equal('Visit Google');

const stateNode = clickableLink?.querySelector(`${nodeSelector}[title="Visit Google"]`);
expect(stateNode, 'clickable state node').to.not.equal(null);
expect(stateNode?.textContent).to.contain('Google');
});
});
}

it('v2 should render a state diagram and set the correct length of the labels', () => {
imgSnapshotTest(
`
Expand Down
42 changes: 42 additions & 0 deletions packages/mermaid/src/diagrams/state/stateDb.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { select } from 'd3';
import DOMPurify from 'dompurify';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import { log } from '../../logger.js';
import { generateId } from '../../utils.js';
Expand All @@ -11,6 +13,7 @@ import {
setAccTitle,
setDiagramTitle,
} from '../common/commonDb.js';
import { createTooltip } from '../common/svgDrawCommon.js';
import { dataFetcher, reset as resetDataFetcher } from './dataFetcher.js';
import { getDir } from './stateRenderer-v3-unified.js';
import {
Expand Down Expand Up @@ -204,6 +207,7 @@ export class StateDB {
private startEndCount = 0;
private dividerCnt = 0;
private links = new Map<string, { url: string; tooltip: string }>();
private funs: ((element: Element) => void)[] = []; // cspell:ignore funs

static readonly relationType = {
AGGREGATION: 0,
Expand All @@ -219,6 +223,7 @@ export class StateDB {
this.getDividerId = this.getDividerId.bind(this);
this.setDirection = this.setDirection.bind(this);
this.trimColon = this.trimColon.bind(this);
this.bindFunctions = this.bindFunctions.bind(this);
}

/**
Expand Down Expand Up @@ -453,6 +458,7 @@ export class StateDB {
clear(saveCommon?: boolean) {
this.nodes = [];
this.edges = [];
this.funs = [this.setupToolTips.bind(this)];
this.documents = { root: newDoc() };
this.currentDocument = this.documents.root;

Expand Down Expand Up @@ -638,6 +644,36 @@ export class StateDB {
return this.classes;
}

private setupToolTips(element: Element) {
const tooltipElem = createTooltip();

const svg = select(element).select('svg');

const nodes = svg.selectAll('g.node, g.rough-node');
nodes
.on('mouseover', (e: MouseEvent) => {
const el = select(e.currentTarget as Element);
const title = el.attr('title');

if (title === null) {
return;
}
const rect = (e.currentTarget as Element)?.getBoundingClientRect();

tooltipElem.transition().duration(200).style('opacity', '.9');
tooltipElem
.style('left', window.scrollX + rect.left + (rect.right - rect.left) / 2 + 'px')
.style('top', window.scrollY + rect.bottom + 'px');
tooltipElem.html(DOMPurify.sanitize(title));
el.classed('hover', true);
})
.on('mouseout', (e: MouseEvent) => {
tooltipElem.transition().duration(500).style('opacity', 0);
const el = select(e.currentTarget as Element);
el.classed('hover', false);
});
}

/**
* Add a (style) class or css class to a state with the given id.
* If the state isn't already in the list of known states, add it.
Expand Down Expand Up @@ -682,6 +718,12 @@ export class StateDB {
this.getState(itemId)?.textStyles?.push(cssClassName);
}

public bindFunctions(element: Element) {
this.funs.forEach((fun) => {
fun(element);
});
}

/**
* Finds the direction statement in the root document.
* @returns the direction statement if present
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';

const renderState = vi.hoisted(() => ({
nodeClass: 'node',
}));

vi.mock('../../diagram-api/diagramAPI.js', () => ({
getConfig: vi.fn(() => ({
securityLevel: 'loose',
state: {
titleTopMargin: 25,
useMaxWidth: true,
nodeSpacing: 50,
rankSpacing: 50,
},
layout: 'dagre',
look: 'classic',
Comment thread
puneetdixit200 marked this conversation as resolved.
})),
}));

vi.mock('../../rendering-util/render.js', () => ({
render: vi.fn((data, svg) => {
const layoutNode = data.nodes.find((node) => node.id === 'A') ?? data.nodes[0];
const node = document.createElementNS('http://www.w3.org/2000/svg', 'g');
node.setAttribute('class', renderState.nodeClass);
node.setAttribute('id', layoutNode?.domId ?? 'state-A-0');

const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
label.textContent = Array.isArray(layoutNode?.label)
? layoutNode.label[0]
: (layoutNode?.label ?? 'Google');
node.appendChild(label);

svg.node().appendChild(node);
}),
}));

vi.mock('../../rendering-util/setupViewPortForSVG.js', () => ({
setupViewPortForSVG: vi.fn(),
}));

import { StateDB } from './stateDb.js';
import { draw } from './stateRenderer-v3-unified.js';

const DIAGRAM_ID = 'state-click-tooltip';

describe('stateRenderer v3 clickable links', () => {
beforeEach(() => {
document.body.innerHTML = `<svg id="${DIAGRAM_ID}"></svg>`;
});

it.each(['node', 'rough-node'])(
'uses mermaidTooltip for state click tooltips on %s elements',
async (nodeClass) => {
renderState.nodeClass = nodeClass;

const stateDb = new StateDB(1);
stateDb.setRootDoc([{ stmt: 'state', id: 'A', description: 'Google' }]);
stateDb.addLink('A', '"https://google.com"', '"Visit Google"');

await draw('', DIAGRAM_ID, '1.0.0', {
type: 'stateDiagram',
db: stateDb,
});

const node = document.querySelector(`svg#${DIAGRAM_ID} a > g.${nodeClass}`);

expect(node).not.toBeNull();
expect(node.getAttribute('title')).toBe('Visit Google');

stateDb.bindFunctions(document.body);

const tooltip = document.querySelector('.mermaidTooltip');
expect(tooltip).not.toBeNull();

node.dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true }));

expect(tooltip.innerHTML).toBe('Visit Google');
expect(node.classList.contains('hover')).toBe(true);

node.dispatchEvent(new window.MouseEvent('mouseout', { bubbles: true }));

expect(node.classList.contains('hover')).toBe(false);
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,19 @@ export const draw = async function (text: string, id: string, _version: string,

links.forEach((linkInfo, key: StateKey) => {
const stateId = typeof key === 'string' ? key : typeof key?.id === 'string' ? key.id : '';
const stateNode = data4Layout.nodes.find((node) => node.id === stateId);

if (!stateId) {
log.warn('⚠️ Invalid or missing stateId from key:', JSON.stringify(key));
return;
}

const allNodes = svg.node()?.querySelectorAll('g');
const allNodes = svg.node()?.querySelectorAll<SVGGElement>('g.node, g.rough-node');
let matchedElem: SVGGElement | undefined;

allNodes?.forEach((g: SVGGElement) => {
const text = g.textContent?.trim();
if (text === stateId) {
if (g.id === stateNode?.domId || text === stateId) {
matchedElem = g;
}
});
Expand All @@ -117,6 +118,7 @@ export const draw = async function (text: string, id: string, _version: string,
if (linkInfo.tooltip) {
const tooltip = linkInfo.tooltip.replace(/^"+|"+$/g, '');
a.setAttribute('title', tooltip);
matchedElem.setAttribute('title', tooltip);
}

parent.replaceChild(a, matchedElem);
Expand Down
Loading