diff --git a/packages/mermaid/src/diagrams/state/parser/stateDiagram.jison b/packages/mermaid/src/diagrams/state/parser/stateDiagram.jison index e3bc51235..80008b545 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.js b/packages/mermaid/src/diagrams/state/stateDb.js index 029db9c6f..5314260ed 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.js +++ b/packages/mermaid/src/diagrams/state/stateDb.js @@ -65,6 +65,8 @@ export class StateDB { constructor(version) { this.clear(); + this.links = new Map(); + this.version = version; // Needed for JISON since it only supports direct properties @@ -125,6 +127,11 @@ export class StateDB { * @type {number} */ dividerCnt = 0; + /** + * @private + * @type {Map} + */ + links = new Map(); static relationType = { AGGREGATION: 0, @@ -278,6 +285,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; } }); @@ -406,6 +416,7 @@ export class StateDB { this.startEndCount = 0; this.classes = newClassesList(); if (!saveCommon) { + this.links = new Map(); commonClear(); } } @@ -570,6 +581,25 @@ export class StateDB { return 'divider-id-' + this.dividerCnt; } + /** + * Adds a clickable link to a state. + * @param {string} stateId + * @param {string} url + * @param {string} tooltip + */ + addLink(stateId, url, tooltip = '') { + this.links.set(stateId, { url, tooltip }); + log.warn('Adding link', stateId, url, tooltip); + } + + /** + * Get all registered links. + * @returns {Map} + */ + getLinks() { + return this.links; + } + /** * Called when the parser comes across a (style) class definition * @example classDef my-style fill:#f96; 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',