Merge pull request #6423 from BambioGaming/feature/6314_state-click

Feature: Added support for clickable states using the click directive
This commit is contained in:
Ashish Jain
2025-05-19 05:56:17 +00:00
committed by GitHub
5 changed files with 149 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
'mermaid': minor
---
Added support for the click directive in stateDiagram syntax

View File

@@ -46,6 +46,10 @@
%% %%
"click" return 'CLICK';
"href" return 'HREF';
\"[^"]*\" return 'STRING';
"default" return 'DEFAULT'; "default" return 'DEFAULT';
.*direction\s+TB[^\n]* return 'direction_tb'; .*direction\s+TB[^\n]* return 'direction_tb';
@@ -246,8 +250,26 @@ statement
| direction | direction
| acc_title acc_title_value { $$=$2.trim();yy.setAccTitle($$); } | acc_title acc_title_value { $$=$2.trim();yy.setAccTitle($$); }
| acc_descr acc_descr_value { $$=$2.trim();yy.setAccDescription($$); } | 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 classDefStatement
: classDef CLASSDEF_ID CLASSDEF_STYLEOPTS { : classDef CLASSDEF_ID CLASSDEF_STYLEOPTS {

View File

@@ -39,7 +39,16 @@ const CONSTANTS = {
} as const; } as const;
interface BaseStmt { 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 { interface ApplyClassStmt extends BaseStmt {
@@ -92,6 +101,13 @@ export interface RootStmt {
doc?: Stmt[]; doc?: Stmt[];
} }
export interface ClickStmt extends BaseStmt {
stmt: 'click';
id: string;
url: string;
tooltip: string;
}
interface Note { interface Note {
position?: 'left of' | 'right of'; position?: 'left of' | 'right of';
text: string; text: string;
@@ -104,7 +120,8 @@ export type Stmt =
| RelationStmt | RelationStmt
| StateStmt | StateStmt
| StyleStmt | StyleStmt
| RootStmt; | RootStmt
| ClickStmt;
interface DiagramEdge { interface DiagramEdge {
id1: string; id1: string;
@@ -185,6 +202,7 @@ export class StateDB {
private currentDocument = this.documents.root; private currentDocument = this.documents.root;
private startEndCount = 0; private startEndCount = 0;
private dividerCnt = 0; private dividerCnt = 0;
private links = new Map<string, { url: string; tooltip: string }>();
static readonly relationType = { static readonly relationType = {
AGGREGATION: 0, AGGREGATION: 0,
@@ -230,6 +248,9 @@ export class StateDB {
case STMT_APPLYCLASS: case STMT_APPLYCLASS:
this.setCssClass(item.id.trim(), item.styleClass); this.setCssClass(item.id.trim(), item.styleClass);
break; break;
case 'click':
this.addLink(item.id, item.url, item.tooltip);
break;
} }
} }
const diagramStates = this.getStates(); const diagramStates = this.getStates();
@@ -438,6 +459,7 @@ export class StateDB {
this.startEndCount = 0; this.startEndCount = 0;
this.classes = newClassesList(); this.classes = newClassesList();
if (!saveCommon) { if (!saveCommon) {
this.links = new Map(); // <-- add here
commonClear(); commonClear();
} }
} }
@@ -458,6 +480,21 @@ export class StateDB {
return this.currentDocument.relations; 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<string, { url: string; tooltip: string }> {
return this.links;
}
/** /**
* If the id is a start node ( [*] ), then return a new id constructed from * If the id is a start node ( [*] ), then return a new id constructed from
* the start node name and the current start node count. * the start node name and the current start node count.

View File

@@ -400,4 +400,30 @@ describe('state diagram, ', function () {
parser.parse(str); 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');
});
});
}); });

View File

@@ -68,6 +68,61 @@ export const draw = async function (text: string, id: string, _version: string,
// console.log('REF1:', data4Layout); // console.log('REF1:', data4Layout);
await render(data4Layout, svg); await render(data4Layout, svg);
const padding = 8; const padding = 8;
// Inject clickable links after nodes are rendered
try {
const links: Map<string, { url: string; tooltip: string }> =
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 <a> tag for:', stateId, linkInfo.url);
});
} catch (err) {
log.error('❌ Error injecting clickable links:', err);
}
utils.insertTitle( utils.insertTitle(
svg, svg,
'statediagramTitleText', 'statediagramTitleText',