diff --git a/.changeset/public-things-stare.md b/.changeset/public-things-stare.md new file mode 100644 index 000000000..f6dc515ee --- /dev/null +++ b/.changeset/public-things-stare.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +Added support for the click directive in stateDiagram syntax diff --git a/packages/mermaid/src/diagrams/state/parser/stateDiagram.jison b/packages/mermaid/src/diagrams/state/parser/stateDiagram.jison index bfaf5a62a..ffc086157 100644 --- a/packages/mermaid/src/diagrams/state/parser/stateDiagram.jison +++ b/packages/mermaid/src/diagrams/state/parser/stateDiagram.jison @@ -46,6 +46,10 @@ %% +"click" return 'CLICK'; +"href" return 'HREF'; +\"[^"]*\" return 'STRING'; + "default" return 'DEFAULT'; .*direction\s+TB[^\n]* return 'direction_tb'; @@ -246,8 +250,26 @@ statement | direction | acc_title acc_title_value { $$=$2.trim();yy.setAccTitle($$); } | acc_descr acc_descr_value { $$=$2.trim();yy.setAccDescription($$); } - | acc_descr_multiline_value { $$=$1.trim();yy.setAccDescription($$); } ; - + | acc_descr_multiline_value { $$=$1.trim();yy.setAccDescription($$); } + | CLICK idStatement STRING STRING NL + { + $$ = { + stmt: "click", + id: $2, + url: $3, + tooltip: $4 + }; + } + | CLICK idStatement HREF STRING NL + { + $$ = { + stmt: "click", + id: $2, + url: $4, + tooltip: "" + }; + } + ; classDefStatement : classDef CLASSDEF_ID CLASSDEF_STYLEOPTS { diff --git a/packages/mermaid/src/diagrams/state/stateDb.ts b/packages/mermaid/src/diagrams/state/stateDb.ts index 853a0e22f..e40b06a9c 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.ts +++ b/packages/mermaid/src/diagrams/state/stateDb.ts @@ -39,7 +39,16 @@ const CONSTANTS = { } as const; interface BaseStmt { - stmt: 'applyClass' | 'classDef' | 'dir' | 'relation' | 'state' | 'style' | 'root' | 'default'; + stmt: + | 'applyClass' + | 'classDef' + | 'dir' + | 'relation' + | 'state' + | 'style' + | 'root' + | 'default' + | 'click'; } interface ApplyClassStmt extends BaseStmt { @@ -92,6 +101,13 @@ export interface RootStmt { doc?: Stmt[]; } +export interface ClickStmt extends BaseStmt { + stmt: 'click'; + id: string; + url: string; + tooltip: string; +} + interface Note { position?: 'left of' | 'right of'; text: string; @@ -104,7 +120,8 @@ export type Stmt = | RelationStmt | StateStmt | StyleStmt - | RootStmt; + | RootStmt + | ClickStmt; interface DiagramEdge { id1: string; @@ -185,6 +202,7 @@ export class StateDB { private currentDocument = this.documents.root; private startEndCount = 0; private dividerCnt = 0; + private links = new Map(); static readonly relationType = { AGGREGATION: 0, @@ -230,6 +248,9 @@ export class StateDB { case STMT_APPLYCLASS: this.setCssClass(item.id.trim(), item.styleClass); break; + case 'click': + this.addLink(item.id, item.url, item.tooltip); + break; } } const diagramStates = this.getStates(); @@ -438,6 +459,7 @@ export class StateDB { this.startEndCount = 0; this.classes = newClassesList(); if (!saveCommon) { + this.links = new Map(); // <-- add here commonClear(); } } @@ -458,6 +480,21 @@ export class StateDB { return this.currentDocument.relations; } + /** + * Adds a clickable link to a state. + */ + addLink(stateId: string, url: string, tooltip: string): void { + this.links.set(stateId, { url, tooltip }); + log.warn('Adding link', stateId, url, tooltip); + } + + /** + * Get all registered links. + */ + getLinks(): Map { + return this.links; + } + /** * If the id is a start node ( [*] ), then return a new id constructed from * the start node name and the current start node count. diff --git a/packages/mermaid/src/diagrams/state/stateDiagram.spec.js b/packages/mermaid/src/diagrams/state/stateDiagram.spec.js index 362c86ccd..c33ed51d7 100644 --- a/packages/mermaid/src/diagrams/state/stateDiagram.spec.js +++ b/packages/mermaid/src/diagrams/state/stateDiagram.spec.js @@ -400,4 +400,30 @@ describe('state diagram, ', function () { parser.parse(str); }); }); + describe('click directive', function () { + let stateDb; + beforeEach(function () { + stateDb = new StateDB(1); + }); + + it('should store links from click statements manually passed to extract()', function () { + const clickStmt = { + stmt: 'click', + id: 'S1', + url: 'https://example.com', + tooltip: 'Go to Example', + }; + + // Add state explicitly + stateDb.addState('S1'); + // Simulate parser output + stateDb.extract([clickStmt]); + + const links = stateDb.getLinks(); + expect(links.has('S1')).toBe(true); + const link = links.get('S1'); + expect(link.url).toBe('https://example.com'); + expect(link.tooltip).toBe('Go to Example'); + }); + }); }); diff --git a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts index 2998c8173..4fcd69706 100644 --- a/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts +++ b/packages/mermaid/src/diagrams/state/stateRenderer-v3-unified.ts @@ -68,6 +68,61 @@ export const draw = async function (text: string, id: string, _version: string, // console.log('REF1:', data4Layout); await render(data4Layout, svg); const padding = 8; + + // Inject clickable links after nodes are rendered + try { + const links: Map = + typeof diag.db.getLinks === 'function' ? diag.db.getLinks() : new Map(); + + type StateKey = string | { id: string }; + + links.forEach((linkInfo, key: StateKey) => { + const stateId = typeof key === 'string' ? key : typeof key?.id === 'string' ? key.id : ''; + + if (!stateId) { + log.warn('⚠️ Invalid or missing stateId from key:', JSON.stringify(key)); + return; + } + + const allNodes = svg.node()?.querySelectorAll('g'); + let matchedElem: SVGGElement | undefined; + + allNodes?.forEach((g: SVGGElement) => { + const text = g.textContent?.trim(); + if (text === stateId) { + matchedElem = g; + } + }); + + if (!matchedElem) { + log.warn('⚠️ Could not find node matching text:', stateId); + return; + } + + const parent = matchedElem.parentNode; + if (!parent) { + log.warn('⚠️ Node has no parent, cannot wrap:', stateId); + return; + } + + const a = document.createElementNS('http://www.w3.org/2000/svg', 'a'); + const cleanedUrl = linkInfo.url.replace(/^"+|"+$/g, ''); // remove leading/trailing quotes + a.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', cleanedUrl); + a.setAttribute('target', '_blank'); + if (linkInfo.tooltip) { + const tooltip = linkInfo.tooltip.replace(/^"+|"+$/g, ''); + a.setAttribute('title', tooltip); + } + + parent.replaceChild(a, matchedElem); + a.appendChild(matchedElem); + + log.info('🔗 Wrapped node in tag for:', stateId, linkInfo.url); + }); + } catch (err) { + log.error('❌ Error injecting clickable links:', err); + } + utils.insertTitle( svg, 'statediagramTitleText',