diff --git a/.changeset/sequence-diagram-styling.md b/.changeset/sequence-diagram-styling.md new file mode 100644 index 00000000000..2b8a84ec909 --- /dev/null +++ b/.changeset/sequence-diagram-styling.md @@ -0,0 +1,5 @@ +--- +'mermaid': minor +--- + +feat(sequence): add style, classDef, and class support for sequence diagram actors (#523) diff --git a/cypress/integration/rendering/sequence/sequencediagram.spec.js b/cypress/integration/rendering/sequence/sequencediagram.spec.js index 524890493d2..53d973544f3 100644 --- a/cypress/integration/rendering/sequence/sequencediagram.spec.js +++ b/cypress/integration/rendering/sequence/sequencediagram.spec.js @@ -1250,4 +1250,110 @@ describe('Sequence diagram', () => { } ); }); + + describe('actor styling and classDef', () => { + it('should render an actor with an inline style', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice + participant Bob + style Alice fill:#f9f,stroke:#333,stroke-width:2px + Alice->>Bob: Hello + Bob-->>Alice: Hi back + ` + ); + }); + + it('should render multiple actors with independent inline styles', () => { + imgSnapshotTest( + ` + sequenceDiagram + participant Alice + participant Bob + participant Carol + style Alice fill:#bbf,stroke:#00f + style Bob fill:#fbb,stroke:#f00 + style Carol fill:#bfb,stroke:#0f0 + Alice->>Bob: One + Bob->>Carol: Two + Carol-->>Alice: Three + ` + ); + }); + + it('should render a classDef defined but not yet attached without changing layout', () => { + // Defining a classDef but never attaching it should be a no-op visually, + // matching how flowchart classDef works. + imgSnapshotTest( + ` + sequenceDiagram + classDef highlighted fill:#f9f,stroke:#333,stroke-width:2px + participant Alice + participant Bob + Alice->>Bob: Hello + ` + ); + }); + + it('should render a classDef attached via the class keyword', () => { + imgSnapshotTest( + ` + sequenceDiagram + classDef highlighted fill:#f9f,stroke:#333,stroke-width:2px + participant Alice + participant Bob + class Alice highlighted + Alice->>Bob: Hello + Bob-->>Alice: Hi + ` + ); + }); + + it('should render a class attached to multiple actors', () => { + imgSnapshotTest( + ` + sequenceDiagram + classDef important fill:#fbb,stroke:#f00,stroke-width:3px + participant Alice + participant Bob + participant Carol + class Alice,Carol important + Alice->>Bob: Hello + Bob->>Carol: Forwarding + ` + ); + }); + + it('should render styled actors inside a box', () => { + imgSnapshotTest( + ` + sequenceDiagram + box Customer Side + participant Alice + participant Bob + end + style Alice fill:#bbf,stroke:#00f + style Bob fill:#fbb,stroke:#f00 + Alice->>Bob: Inside the box + ` + ); + }); + + it('should render styled actors with the dark theme', () => { + imgSnapshotTest( + ` + %%{init: {'theme': 'dark'}}%% + sequenceDiagram + classDef highlighted fill:#665,stroke:#fa0,stroke-width:2px + participant Alice + participant Bob + class Alice highlighted + style Bob fill:#446,stroke:#88f + Alice->>Bob: Hello in dark mode + Bob-->>Alice: Hi back + ` + ); + }); + }); }); diff --git a/docs/syntax/sequenceDiagram.md b/docs/syntax/sequenceDiagram.md index 7b4621236b2..c82658bd523 100644 --- a/docs/syntax/sequenceDiagram.md +++ b/docs/syntax/sequenceDiagram.md @@ -1158,6 +1158,58 @@ text.actor { } ``` +### Per-actor styling (v\+) + +Individual actors can be styled inline or by attaching a reusable class. This addresses [issue #523](https://github.com/mermaid-js/mermaid/issues/523), the long-standing request to support per-actor colors in sequence diagrams. + +**Inline `style` statement.** Apply styles to a single actor by name: + +```mermaid-example +sequenceDiagram + participant Alice + participant Bob + style Alice fill:#f9f,stroke:#333,stroke-width:2px + Alice->>Bob: Hello + Bob-->>Alice: Hi back +``` + +```mermaid +sequenceDiagram + participant Alice + participant Bob + style Alice fill:#f9f,stroke:#333,stroke-width:2px + Alice->>Bob: Hello + Bob-->>Alice: Hi back +``` + +**Reusable `classDef` and `class` statements.** Define a class once and attach it to one or more actors. This mirrors the `classDef` / `class` pattern used in flowchart diagrams: + +```mermaid-example +sequenceDiagram + classDef important fill:#fbb,stroke:#f00,stroke-width:3px + participant Alice + participant Bob + participant Carol + class Alice,Carol important + Alice->>Bob: Hello + Bob->>Carol: Forwarding +``` + +```mermaid +sequenceDiagram + classDef important fill:#fbb,stroke:#f00,stroke-width:3px + participant Alice + participant Bob + participant Carol + class Alice,Carol important + Alice->>Bob: Hello + Bob->>Carol: Forwarding +``` + +The supported declarations include `fill`, `stroke`, `stroke-width`, `stroke-dasharray`, `opacity`, and the `color` and `font-*` properties (which are routed to the actor's text element). Declarations are sanitized — values containing `url(...)`, `expression(...)`, `behavior:`, `javascript:`, `@import`, or rule terminators are dropped, so user-supplied styles cannot reach external resources or break out of the diagram's scoped stylesheet. + +This first release covers actor boxes only. Styling for arrows, notes, and loops is planned as a follow-up. + ## Configuration It is possible to adjust the margins for rendering the sequence diagram. diff --git a/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison b/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison index fee16887cb0..a863dd84e70 100644 --- a/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison +++ b/packages/mermaid/src/diagrams/sequence/parser/sequenceDiagram.jison @@ -14,7 +14,7 @@ // Special states for recognizing aliases // A special state for grabbing text up to the first comment/newline -%x ID ALIAS LINE CONFIG CONFIG_DATA +%x ID ALIAS LINE STYLE_STMT CONFIG CONFIG_DATA %x acc_title %x acc_descr @@ -56,6 +56,11 @@ "option" { this.begin('LINE'); return 'option'; } "break" { this.begin('LINE'); return 'break'; } (?:[:]?(?:no)?wrap:)?[^#\n;]* { this.popState(); return 'restOfLine'; } +((?!\n)\s)+ /* skip same-line whitespace */ +[^\n;]* { this.popState(); return 'styleRestOfLine'; } +classDef(?=\s) { this.begin('STYLE_STMT'); return 'classDef'; } +style(?=\s) { this.begin('STYLE_STMT'); return 'style'; } +class(?=\s) { this.begin('STYLE_STMT'); return 'class'; } "end" return 'end'; "left of" return 'left_of'; "right of" return 'right_of'; @@ -188,6 +193,9 @@ statement | 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($$); } + | 'classDef' styleRestOfLine { var m = $2.trim().match(/^(\S+)\s+([\s\S]*)/); if(m) { yy.addClass(m[1], [m[2]]); } } + | 'style' styleRestOfLine { var m = $2.trim().match(/^(\S+)\s+([\s\S]*)/); if(m) { $$ = {type: 'applyStyle', actor: m[1], styleStr: [m[2]]}; } } + | 'class' styleRestOfLine { var m = $2.trim().match(/^(\S+)\s+([\s\S]+)/); if(m) { $$ = {type: 'applyClass', actor: m[1], className: m[2].trim()}; } } | 'loop' restOfLine document end { $3.unshift({type: 'loopStart', loopText:yy.parseMessage($2), signalType: yy.LINETYPE.LOOP_START}); diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts index 3d79b9ea616..13ed5d04e74 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts +++ b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts @@ -13,7 +13,8 @@ import { setAccTitle, setDiagramTitle, } from '../common/commonDb.js'; -import type { Actor, AddMessageParams, Box, Message, Note } from './types.js'; +import type { Actor, AddMessageParams, Box, Message, Note, SequenceClass } from './types.js'; +import type { DiagramStyleClassDef } from '../../diagram-api/types.js'; import type { ParticipantMetaData } from '../../types.js'; interface SequenceState { @@ -29,6 +30,7 @@ interface SequenceState { currentBox?: Box; lastCreated?: Actor; lastDestroyed?: Actor; + classes: Map; } const LINETYPE = { @@ -126,6 +128,7 @@ export class SequenceDB implements DiagramDB { currentBox: undefined, lastCreated: undefined, lastDestroyed: undefined, + classes: new Map(), })); constructor() { @@ -152,6 +155,179 @@ export class SequenceDB implements DiagramDB { this.state.records.currentBox = this.state.records.boxes.slice(-1)[0]; } + // Strip CSS declarations that could be used to exfiltrate data or break + // out of the scoped style tag. The upstream encodeEntities() in utils.ts + // already strips trailing ';' from classDef lines, which (as a side effect + // of its greedy regex) can merge values across an embedded ';'. This + // filter is the line of defense for whatever survives that step. + // + // Rejected patterns: + // url(...) - fetches external resources, leaks page state + // expression(...) - legacy IE JS execution + // behavior: - legacy IE behavior loading + // javascript: - protocol injection + // import at-rule - pulls in arbitrary stylesheets + // right brace - rule terminator, would close the scoped block + // left angle - guards against angle-bracket smuggling + private sanitizeCssDeclaration(decl: string): string | null { + // Strip CSS comments before checking — url/* comment */(evil) is valid + // CSS but would bypass a naive /url\s*\(/ check. + const stripped = decl.toLowerCase().replace(/\/\*[\S\s]*?\*\//g, ''); + if ( + /url\s*\(/.test(stripped) || + /expression\s*\(/.test(stripped) || + /\bbehavior\s*:/.test(stripped) || + /\bjavascript\s*:/.test(stripped) || + stripped.includes('@import') || + decl.includes('}') || + decl.includes('<') + ) { + return null; + } + return decl; + } + + public addClass = (id: string, styleStr: string[]) => { + const styles = styleStr + .join() + .replace(/\\,/g, '\u00a7\u00a7\u00a7') + .replace(/,/g, ';') + .replace(/\u00a7{3}/g, ',') + .split(';') + .map((s) => this.sanitizeCssDeclaration(s.trim())) + .filter((s): s is string => s !== null && s.length > 0); + + const textStyles: string[] = []; + const elementStyles: string[] = []; + + for (const s of styles) { + const prop = s.trim(); + if (!prop) { + continue; + } + if (prop.startsWith('color:') || prop.startsWith('font-')) { + textStyles.push(prop); + } else { + elementStyles.push(prop); + } + } + + const trimmedId = id.trim(); + if (trimmedId) { + this.state.records.classes.set(trimmedId, { + id: trimmedId, + styles: elementStyles, + textStyles, + }); + } + }; + + /** + * Apply inline styles to a specific actor/participant. + * + * Syntax: style Alice fill:#f9f,stroke:#333; + * + * Arrow function property for the same JISON reason as addClass. + */ + public setActorStyle = (actorId: string, styleStr: string[]) => { + const actor = this.state.records.actors.get(actorId); + if (!actor) { + return; + } + + const styles = styleStr + .join() + .replace(/\\,/g, '\u00a7\u00a7\u00a7') + .replace(/,/g, ';') + .replace(/\u00a7{3}/g, ',') + .split(';') + .map((s) => this.sanitizeCssDeclaration(s.trim())) + .filter((s): s is string => s !== null && s.length > 0); + + actor.styles = [...(actor.styles || []), ...styles]; + }; + + /** + * Attach a previously-defined class (via classDef) to one or more actors. + * + * Syntax: class Alice,Bob highlighted + * + * Mirrors flowDb.setClass — splits ids on comma, looks up each actor, + * and pushes the className onto its classes array. Unknown actors are + * skipped silently to match flowchart behavior. + * + * Arrow function property so JISON's yy can find it. + */ + public setCssClass = (ids: string, className: string) => { + for (const id of ids.split(',')) { + const trimmedId = id.trim(); + if (!trimmedId) { + continue; + } + const actor = this.state.records.actors.get(trimmedId); + if (actor) { + actor.classes = [...(actor.classes ?? []), className]; + } + } + }; + + /** + * Get all defined classes, including generated classes for actors with + * inline styles. Returns a Map so mermaidAPI's createUserStyles() can + * compile them into a scoped