diff --git a/.changeset/state-tooltip-links.md b/.changeset/state-tooltip-links.md new file mode 100644 index 00000000000..f7c08ad3444 --- /dev/null +++ b/.changeset/state-tooltip-links.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: render state diagram click tooltips with mermaidTooltip diff --git a/cypress/integration/rendering/state/stateDiagram-v2.spec.js b/cypress/integration/rendering/state/stateDiagram-v2.spec.js index d22c86e451e..19ae123abbc 100644 --- a/cypress/integration/rendering/state/stateDiagram-v2.spec.js +++ b/cypress/integration/rendering/state/stateDiagram-v2.spec.js @@ -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( ` @@ -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( ` diff --git a/packages/mermaid/src/diagrams/state/stateDb.ts b/packages/mermaid/src/diagrams/state/stateDb.ts index adb15d661d3..b39d1f81d61 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.ts +++ b/packages/mermaid/src/diagrams/state/stateDb.ts @@ -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'; @@ -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 { @@ -204,6 +207,7 @@ export class StateDB { private startEndCount = 0; private dividerCnt = 0; private links = new Map(); + private funs: ((element: Element) => void)[] = []; // cspell:ignore funs static readonly relationType = { AGGREGATION: 0, @@ -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); } /** @@ -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; @@ -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. @@ -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 diff --git a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.spec.js b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.spec.js new file mode 100644 index 00000000000..e005f5cad7c --- /dev/null +++ b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.spec.js @@ -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', + })), +})); + +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 = ``; + }); + + 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); + } + ); +}); diff --git a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts index 13359b649a6..8621b6a422f 100644 --- a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts +++ b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts @@ -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('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; } }); @@ -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);