mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-08-21 17:26:45 +02:00
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:
5
.changeset/public-things-stare.md
Normal file
5
.changeset/public-things-stare.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'mermaid': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added support for the click directive in stateDiagram syntax
|
@@ -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 {
|
||||||
|
@@ -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.
|
||||||
|
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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',
|
||||||
|
Reference in New Issue
Block a user