diff --git a/.changeset/chilly-years-cheat.md b/.changeset/chilly-years-cheat.md new file mode 100644 index 000000000..e665af75b --- /dev/null +++ b/.changeset/chilly-years-cheat.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: `mermaidAPI.getDiagramFromText()` now returns a new different db for each class diagram diff --git a/.changeset/witty-crews-smell.md b/.changeset/witty-crews-smell.md new file mode 100644 index 000000000..4213083f2 --- /dev/null +++ b/.changeset/witty-crews-smell.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +`mermaidAPI.getDiagramFromText()` now returns a new different db for each state diagram diff --git a/packages/mermaid/src/diagrams/class/classDb.ts b/packages/mermaid/src/diagrams/class/classDb.ts index 569943736..b2c3c944c 100644 --- a/packages/mermaid/src/diagrams/class/classDb.ts +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -24,713 +24,716 @@ import type { Interface, } from './classTypes.js'; import type { Node, Edge } from '../../rendering-util/types.js'; +import type { DiagramDB } from '../../diagram-api/types.js'; const MERMAID_DOM_ID_PREFIX = 'classId-'; - -let relations: ClassRelation[] = []; -let classes = new Map(); -const styleClasses = new Map(); -let notes: ClassNote[] = []; -let interfaces: Interface[] = []; let classCounter = 0; -let namespaces = new Map(); -let namespaceCounter = 0; - -let functions: any[] = []; const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig()); -const splitClassNameAndType = function (_id: string) { - const id = common.sanitizeText(_id, getConfig()); - let genericType = ''; - let className = id; +export class ClassDB implements DiagramDB { + private relations: ClassRelation[] = []; + private classes = new Map(); + private readonly styleClasses = new Map(); + private notes: ClassNote[] = []; + private interfaces: Interface[] = []; + // private static classCounter = 0; + private namespaces = new Map(); + private namespaceCounter = 0; - if (id.indexOf('~') > 0) { - const split = id.split('~'); - className = sanitizeText(split[0]); - genericType = sanitizeText(split[1]); + private functions: any[] = []; + + constructor() { + this.functions.push(this.setupToolTips.bind(this)); + this.clear(); + + // Needed for JISON since it only supports direct properties + this.addRelation = this.addRelation.bind(this); + this.addClassesToNamespace = this.addClassesToNamespace.bind(this); + this.addNamespace = this.addNamespace.bind(this); + this.setCssClass = this.setCssClass.bind(this); + this.addMembers = this.addMembers.bind(this); + this.addClass = this.addClass.bind(this); + this.setClassLabel = this.setClassLabel.bind(this); + this.addAnnotation = this.addAnnotation.bind(this); + this.addMember = this.addMember.bind(this); + this.cleanupLabel = this.cleanupLabel.bind(this); + this.addNote = this.addNote.bind(this); + this.defineClass = this.defineClass.bind(this); + this.setDirection = this.setDirection.bind(this); + this.setLink = this.setLink.bind(this); + this.bindFunctions = this.bindFunctions.bind(this); + this.clear = this.clear.bind(this); + + this.setTooltip = this.setTooltip.bind(this); + this.setClickEvent = this.setClickEvent.bind(this); + this.setCssStyle = this.setCssStyle.bind(this); } - return { className: className, type: genericType }; -}; + private splitClassNameAndType(_id: string) { + const id = common.sanitizeText(_id, getConfig()); + let genericType = ''; + let className = id; -export const setClassLabel = function (_id: string, label: string) { - const id = common.sanitizeText(_id, getConfig()); - if (label) { - label = sanitizeText(label); - } - - const { className } = splitClassNameAndType(id); - classes.get(className)!.label = label; - classes.get(className)!.text = - `${label}${classes.get(className)!.type ? `<${classes.get(className)!.type}>` : ''}`; -}; - -/** - * Function called by parser when a node definition has been found. - * - * @param id - Id of the class to add - * @public - */ -export const addClass = function (_id: string) { - const id = common.sanitizeText(_id, getConfig()); - const { className, type } = splitClassNameAndType(id); - // Only add class if not exists - if (classes.has(className)) { - return; - } - // alert('Adding class: ' + className); - const name = common.sanitizeText(className, getConfig()); - // alert('Adding class after: ' + name); - classes.set(name, { - id: name, - type: type, - label: name, - text: `${name}${type ? `<${type}>` : ''}`, - shape: 'classBox', - cssClasses: 'default', - methods: [], - members: [], - annotations: [], - styles: [], - domId: MERMAID_DOM_ID_PREFIX + name + '-' + classCounter, - } as ClassNode); - - classCounter++; -}; - -const addInterface = function (label: string, classId: string) { - const classInterface: Interface = { - id: `interface${interfaces.length}`, - label, - classId, - }; - - interfaces.push(classInterface); -}; - -/** - * Function to lookup domId from id in the graph definition. - * - * @param id - class ID to lookup - * @public - */ -export const lookUpDomId = function (_id: string): string { - const id = common.sanitizeText(_id, getConfig()); - if (classes.has(id)) { - return classes.get(id)!.domId; - } - throw new Error('Class not found: ' + id); -}; - -export const clear = function () { - relations = []; - classes = new Map(); - notes = []; - interfaces = []; - functions = []; - functions.push(setupToolTips); - namespaces = new Map(); - namespaceCounter = 0; - direction = 'TB'; - commonClear(); -}; - -export const getClass = function (id: string): ClassNode { - return classes.get(id)!; -}; - -export const getClasses = function (): ClassMap { - return classes; -}; - -export const getRelations = function (): ClassRelation[] { - return relations; -}; - -export const getNotes = function () { - return notes; -}; - -export const addRelation = function (classRelation: ClassRelation) { - log.debug('Adding relation: ' + JSON.stringify(classRelation)); - // Due to relationType cannot just check if it is equal to 'none' or it complains, can fix this later - const invalidTypes = [ - relationType.LOLLIPOP, - relationType.AGGREGATION, - relationType.COMPOSITION, - relationType.DEPENDENCY, - relationType.EXTENSION, - ]; - - if ( - classRelation.relation.type1 === relationType.LOLLIPOP && - !invalidTypes.includes(classRelation.relation.type2) - ) { - addClass(classRelation.id2); - addInterface(classRelation.id1, classRelation.id2); - classRelation.id1 = `interface${interfaces.length - 1}`; - } else if ( - classRelation.relation.type2 === relationType.LOLLIPOP && - !invalidTypes.includes(classRelation.relation.type1) - ) { - addClass(classRelation.id1); - addInterface(classRelation.id2, classRelation.id1); - classRelation.id2 = `interface${interfaces.length - 1}`; - } else { - addClass(classRelation.id1); - addClass(classRelation.id2); - } - - classRelation.id1 = splitClassNameAndType(classRelation.id1).className; - classRelation.id2 = splitClassNameAndType(classRelation.id2).className; - - classRelation.relationTitle1 = common.sanitizeText( - classRelation.relationTitle1.trim(), - getConfig() - ); - - classRelation.relationTitle2 = common.sanitizeText( - classRelation.relationTitle2.trim(), - getConfig() - ); - - relations.push(classRelation); -}; - -/** - * Adds an annotation to the specified class Annotations mark special properties of the given type - * (like 'interface' or 'service') - * - * @param className - The class name - * @param annotation - The name of the annotation without any brackets - * @public - */ -export const addAnnotation = function (className: string, annotation: string) { - const validatedClassName = splitClassNameAndType(className).className; - classes.get(validatedClassName)!.annotations.push(annotation); -}; - -/** - * Adds a member to the specified class - * - * @param className - The class name - * @param member - The full name of the member. If the member is enclosed in `<>` it is - * treated as an annotation If the member is ending with a closing bracket ) it is treated as a - * method Otherwise the member will be treated as a normal property - * @public - */ -export const addMember = function (className: string, member: string) { - addClass(className); - - const validatedClassName = splitClassNameAndType(className).className; - const theClass = classes.get(validatedClassName)!; - - if (typeof member === 'string') { - // Member can contain white spaces, we trim them out - const memberString = member.trim(); - - if (memberString.startsWith('<<') && memberString.endsWith('>>')) { - // its an annotation - theClass.annotations.push(sanitizeText(memberString.substring(2, memberString.length - 2))); - } else if (memberString.indexOf(')') > 0) { - //its a method - theClass.methods.push(new ClassMember(memberString, 'method')); - } else if (memberString) { - theClass.members.push(new ClassMember(memberString, 'attribute')); - } - } -}; - -export const addMembers = function (className: string, members: string[]) { - if (Array.isArray(members)) { - members.reverse(); - members.forEach((member) => addMember(className, member)); - } -}; - -export const addNote = function (text: string, className: string) { - const note = { - id: `note${notes.length}`, - class: className, - text: text, - }; - notes.push(note); -}; - -export const cleanupLabel = function (label: string) { - if (label.startsWith(':')) { - label = label.substring(1); - } - return sanitizeText(label.trim()); -}; - -/** - * Called by parser when assigning cssClass to a class - * - * @param ids - Comma separated list of ids - * @param className - Class to add - */ -export const setCssClass = function (ids: string, className: string) { - ids.split(',').forEach(function (_id) { - let id = _id; - if (/\d/.exec(_id[0])) { - id = MERMAID_DOM_ID_PREFIX + id; - } - const classNode = classes.get(id); - if (classNode) { - classNode.cssClasses += ' ' + className; - } - }); -}; - -export const defineClass = function (ids: string[], style: string[]) { - for (const id of ids) { - let styleClass = styleClasses.get(id); - if (styleClass === undefined) { - styleClass = { id, styles: [], textStyles: [] }; - styleClasses.set(id, styleClass); + if (id.indexOf('~') > 0) { + const split = id.split('~'); + className = sanitizeText(split[0]); + genericType = sanitizeText(split[1]); } - if (style) { - style.forEach(function (s) { - if (/color/.exec(s)) { - const newStyle = s.replace('fill', 'bgFill'); // .replace('color', 'fill'); - styleClass.textStyles.push(newStyle); + return { className: className, type: genericType }; + } + + public setClassLabel(_id: string, label: string) { + const id = common.sanitizeText(_id, getConfig()); + if (label) { + label = sanitizeText(label); + } + + const { className } = this.splitClassNameAndType(id); + this.classes.get(className)!.label = label; + this.classes.get(className)!.text = + `${label}${this.classes.get(className)!.type ? `<${this.classes.get(className)!.type}>` : ''}`; + } + + /** + * Function called by parser when a node definition has been found. + * + * @param id - Id of the class to add + * @public + */ + public addClass(_id: string) { + const id = common.sanitizeText(_id, getConfig()); + const { className, type } = this.splitClassNameAndType(id); + // Only add class if not exists + if (this.classes.has(className)) { + return; + } + // alert('Adding class: ' + className); + const name = common.sanitizeText(className, getConfig()); + // alert('Adding class after: ' + name); + this.classes.set(name, { + id: name, + type: type, + label: name, + text: `${name}${type ? `<${type}>` : ''}`, + shape: 'classBox', + cssClasses: 'default', + methods: [], + members: [], + annotations: [], + styles: [], + domId: MERMAID_DOM_ID_PREFIX + name + '-' + classCounter, + } as ClassNode); + + classCounter++; + } + + private addInterface(label: string, classId: string) { + const classInterface: Interface = { + id: `interface${this.interfaces.length}`, + label, + classId, + }; + + this.interfaces.push(classInterface); + } + + /** + * Function to lookup domId from id in the graph definition. + * + * @param id - class ID to lookup + * @public + */ + public lookUpDomId(_id: string): string { + const id = common.sanitizeText(_id, getConfig()); + if (this.classes.has(id)) { + return this.classes.get(id)!.domId; + } + throw new Error('Class not found: ' + id); + } + + public clear() { + this.relations = []; + this.classes = new Map(); + this.notes = []; + this.interfaces = []; + this.functions = []; + this.functions.push(this.setupToolTips.bind(this)); + this.namespaces = new Map(); + this.namespaceCounter = 0; + this.direction = 'TB'; + commonClear(); + } + + public getClass(id: string): ClassNode { + return this.classes.get(id)!; + } + + public getClasses(): ClassMap { + return this.classes; + } + + public getRelations(): ClassRelation[] { + return this.relations; + } + + public getNotes() { + return this.notes; + } + + public addRelation(classRelation: ClassRelation) { + log.debug('Adding relation: ' + JSON.stringify(classRelation)); + // Due to relationType cannot just check if it is equal to 'none' or it complains, can fix this later + const invalidTypes = [ + this.relationType.LOLLIPOP, + this.relationType.AGGREGATION, + this.relationType.COMPOSITION, + this.relationType.DEPENDENCY, + this.relationType.EXTENSION, + ]; + + if ( + classRelation.relation.type1 === this.relationType.LOLLIPOP && + !invalidTypes.includes(classRelation.relation.type2) + ) { + this.addClass(classRelation.id2); + this.addInterface(classRelation.id1, classRelation.id2); + classRelation.id1 = `interface${this.interfaces.length - 1}`; + } else if ( + classRelation.relation.type2 === this.relationType.LOLLIPOP && + !invalidTypes.includes(classRelation.relation.type1) + ) { + this.addClass(classRelation.id1); + this.addInterface(classRelation.id2, classRelation.id1); + classRelation.id2 = `interface${this.interfaces.length - 1}`; + } else { + this.addClass(classRelation.id1); + this.addClass(classRelation.id2); + } + + classRelation.id1 = this.splitClassNameAndType(classRelation.id1).className; + classRelation.id2 = this.splitClassNameAndType(classRelation.id2).className; + + classRelation.relationTitle1 = common.sanitizeText( + classRelation.relationTitle1.trim(), + getConfig() + ); + + classRelation.relationTitle2 = common.sanitizeText( + classRelation.relationTitle2.trim(), + getConfig() + ); + + this.relations.push(classRelation); + } + + /** + * Adds an annotation to the specified class Annotations mark special properties of the given type + * (like 'interface' or 'service') + * + * @param className - The class name + * @param annotation - The name of the annotation without any brackets + * @public + */ + public addAnnotation(className: string, annotation: string) { + const validatedClassName = this.splitClassNameAndType(className).className; + this.classes.get(validatedClassName)!.annotations.push(annotation); + } + + /** + * Adds a member to the specified class + * + * @param className - The class name + * @param member - The full name of the member. If the member is enclosed in `<>` it is + * treated as an annotation If the member is ending with a closing bracket ) it is treated as a + * method Otherwise the member will be treated as a normal property + * @public + */ + public addMember(className: string, member: string) { + this.addClass(className); + + const validatedClassName = this.splitClassNameAndType(className).className; + const theClass = this.classes.get(validatedClassName)!; + + if (typeof member === 'string') { + // Member can contain white spaces, we trim them out + const memberString = member.trim(); + + if (memberString.startsWith('<<') && memberString.endsWith('>>')) { + // its an annotation + theClass.annotations.push(sanitizeText(memberString.substring(2, memberString.length - 2))); + } else if (memberString.indexOf(')') > 0) { + //its a method + theClass.methods.push(new ClassMember(memberString, 'method')); + } else if (memberString) { + theClass.members.push(new ClassMember(memberString, 'attribute')); + } + } + } + + public addMembers(className: string, members: string[]) { + if (Array.isArray(members)) { + members.reverse(); + members.forEach((member) => this.addMember(className, member)); + } + } + + public addNote(text: string, className: string) { + const note = { + id: `note${this.notes.length}`, + class: className, + text: text, + }; + this.notes.push(note); + } + + public cleanupLabel(label: string) { + if (label.startsWith(':')) { + label = label.substring(1); + } + return sanitizeText(label.trim()); + } + + /** + * Called by parser when assigning cssClass to a class + * + * @param ids - Comma separated list of ids + * @param className - Class to add + */ + public setCssClass(ids: string, className: string) { + ids.split(',').forEach((_id) => { + let id = _id; + if (/\d/.exec(_id[0])) { + id = MERMAID_DOM_ID_PREFIX + id; + } + const classNode = this.classes.get(id); + if (classNode) { + classNode.cssClasses += ' ' + className; + } + }); + } + + public defineClass(ids: string[], style: string[]) { + for (const id of ids) { + let styleClass = this.styleClasses.get(id); + if (styleClass === undefined) { + styleClass = { id, styles: [], textStyles: [] }; + this.styleClasses.set(id, styleClass); + } + + if (style) { + style.forEach((s) => { + if (/color/.exec(s)) { + const newStyle = s.replace('fill', 'bgFill'); // .replace('color', 'fill'); + styleClass.textStyles.push(newStyle); + } + styleClass.styles.push(s); + }); + } + + this.classes.forEach((value) => { + if (value.cssClasses.includes(id)) { + value.styles.push(...style.flatMap((s) => s.split(','))); } - styleClass.styles.push(s); }); } + } - classes.forEach((value) => { - if (value.cssClasses.includes(id)) { - value.styles.push(...style.flatMap((s) => s.split(','))); + /** + * Called by parser when a tooltip is found, e.g. a clickable element. + * + * @param ids - Comma separated list of ids + * @param tooltip - Tooltip to add + */ + public setTooltip(ids: string, tooltip?: string) { + ids.split(',').forEach((id) => { + if (tooltip !== undefined) { + this.classes.get(id)!.tooltip = sanitizeText(tooltip); } }); } -}; -/** - * Called by parser when a tooltip is found, e.g. a clickable element. - * - * @param ids - Comma separated list of ids - * @param tooltip - Tooltip to add - */ -const setTooltip = function (ids: string, tooltip?: string) { - ids.split(',').forEach(function (id) { - if (tooltip !== undefined) { - classes.get(id)!.tooltip = sanitizeText(tooltip); + public getTooltip(id: string, namespace?: string) { + if (namespace && this.namespaces.has(namespace)) { + return this.namespaces.get(namespace)!.classes.get(id)!.tooltip; } - }); -}; -export const getTooltip = function (id: string, namespace?: string) { - if (namespace && namespaces.has(namespace)) { - return namespaces.get(namespace)!.classes.get(id)!.tooltip; + return this.classes.get(id)!.tooltip; } - return classes.get(id)!.tooltip; -}; - -/** - * Called by parser when a link is found. Adds the URL to the vertex data. - * - * @param ids - Comma separated list of ids - * @param linkStr - URL to create a link for - * @param target - Target of the link, _blank by default as originally defined in the svgDraw.js file - */ -export const setLink = function (ids: string, linkStr: string, target: string) { - const config = getConfig(); - ids.split(',').forEach(function (_id) { - let id = _id; - if (/\d/.exec(_id[0])) { - id = MERMAID_DOM_ID_PREFIX + id; - } - const theClass = classes.get(id); - if (theClass) { - theClass.link = utils.formatUrl(linkStr, config); - if (config.securityLevel === 'sandbox') { - theClass.linkTarget = '_top'; - } else if (typeof target === 'string') { - theClass.linkTarget = sanitizeText(target); - } else { - theClass.linkTarget = '_blank'; + /** + * Called by parser when a link is found. Adds the URL to the vertex data. + * + * @param ids - Comma separated list of ids + * @param linkStr - URL to create a link for + * @param target - Target of the link, _blank by default as originally defined in the svgDraw.js file + */ + public setLink(ids: string, linkStr: string, target: string) { + const config = getConfig(); + ids.split(',').forEach((_id) => { + let id = _id; + if (/\d/.exec(_id[0])) { + id = MERMAID_DOM_ID_PREFIX + id; } - } - }); - setCssClass(ids, 'clickable'); -}; - -/** - * Called by parser when a click definition is found. Registers an event handler. - * - * @param ids - Comma separated list of ids - * @param functionName - Function to be called on click - * @param functionArgs - Function args the function should be called with - */ -export const setClickEvent = function (ids: string, functionName: string, functionArgs: string) { - ids.split(',').forEach(function (id) { - setClickFunc(id, functionName, functionArgs); - classes.get(id)!.haveCallback = true; - }); - setCssClass(ids, 'clickable'); -}; - -const setClickFunc = function (_domId: string, functionName: string, functionArgs: string) { - const domId = common.sanitizeText(_domId, getConfig()); - const config = getConfig(); - if (config.securityLevel !== 'loose') { - return; - } - if (functionName === undefined) { - return; - } - - const id = domId; - if (classes.has(id)) { - const elemId = lookUpDomId(id); - let argList: string[] = []; - if (typeof functionArgs === 'string') { - /* Splits functionArgs by ',', ignoring all ',' in double quoted strings */ - argList = functionArgs.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/); - for (let i = 0; i < argList.length; i++) { - let item = argList[i].trim(); - /* Removes all double quotes at the start and end of an argument */ - /* This preserves all starting and ending whitespace inside */ - if (item.startsWith('"') && item.endsWith('"')) { - item = item.substr(1, item.length - 2); + const theClass = this.classes.get(id); + if (theClass) { + theClass.link = utils.formatUrl(linkStr, config); + if (config.securityLevel === 'sandbox') { + theClass.linkTarget = '_top'; + } else if (typeof target === 'string') { + theClass.linkTarget = sanitizeText(target); + } else { + theClass.linkTarget = '_blank'; } - argList[i] = item; - } - } - - /* if no arguments passed into callback, default to passing in id */ - if (argList.length === 0) { - argList.push(elemId); - } - - functions.push(function () { - const elem = document.querySelector(`[id="${elemId}"]`); - if (elem !== null) { - elem.addEventListener( - 'click', - function () { - utils.runFunc(functionName, ...argList); - }, - false - ); } }); - } -}; - -export const bindFunctions = function (element: Element) { - functions.forEach(function (fun) { - fun(element); - }); -}; - -export const lineType = { - LINE: 0, - DOTTED_LINE: 1, -}; - -export const relationType = { - AGGREGATION: 0, - EXTENSION: 1, - COMPOSITION: 2, - DEPENDENCY: 3, - LOLLIPOP: 4, -}; - -const setupToolTips = function (element: Element) { - let tooltipElem: Selection = - select('.mermaidTooltip'); - // @ts-expect-error - Incorrect types - if ((tooltipElem._groups || tooltipElem)[0][0] === null) { - tooltipElem = select('body').append('div').attr('class', 'mermaidTooltip').style('opacity', 0); + this.setCssClass(ids, 'clickable'); } - const svg = select(element).select('svg'); - - const nodes = svg.selectAll('g.node'); - nodes - .on('mouseover', function () { - const el = select(this); - const title = el.attr('title'); - // Don't try to draw a tooltip if no data is provided - if (title === null) { - return; - } - // @ts-ignore - getBoundingClientRect is not part of the d3 type definition - const rect = this.getBoundingClientRect(); - - tooltipElem.transition().duration(200).style('opacity', '.9'); - tooltipElem - .text(el.attr('title')) - .style('left', window.scrollX + rect.left + (rect.right - rect.left) / 2 + 'px') - .style('top', window.scrollY + rect.top - 14 + document.body.scrollTop + 'px'); - tooltipElem.html(tooltipElem.html().replace(/<br\/>/g, '
')); - el.classed('hover', true); - }) - .on('mouseout', function () { - tooltipElem.transition().duration(500).style('opacity', 0); - const el = select(this); - el.classed('hover', false); + /** + * Called by parser when a click definition is found. Registers an event handler. + * + * @param ids - Comma separated list of ids + * @param functionName - Function to be called on click + * @param functionArgs - Function args the function should be called with + */ + public setClickEvent(ids: string, functionName: string, functionArgs: string) { + ids.split(',').forEach((id) => { + this.setClickFunc(id, functionName, functionArgs); + this.classes.get(id)!.haveCallback = true; }); -}; -functions.push(setupToolTips); - -let direction = 'TB'; -const getDirection = () => direction; -const setDirection = (dir: string) => { - direction = dir; -}; - -/** - * Function called by parser when a namespace definition has been found. - * - * @param id - Id of the namespace to add - * @public - */ -export const addNamespace = function (id: string) { - if (namespaces.has(id)) { - return; + this.setCssClass(ids, 'clickable'); } - namespaces.set(id, { - id: id, - classes: new Map(), - children: {}, - domId: MERMAID_DOM_ID_PREFIX + id + '-' + namespaceCounter, - } as NamespaceNode); + private setClickFunc(_domId: string, functionName: string, functionArgs: string) { + const domId = common.sanitizeText(_domId, getConfig()); + const config = getConfig(); + if (config.securityLevel !== 'loose') { + return; + } + if (functionName === undefined) { + return; + } - namespaceCounter++; -}; + const id = domId; + if (this.classes.has(id)) { + const elemId = this.lookUpDomId(id); + let argList: string[] = []; + if (typeof functionArgs === 'string') { + /* Splits functionArgs by ',', ignoring all ',' in double quoted strings */ + argList = functionArgs.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/); + for (let i = 0; i < argList.length; i++) { + let item = argList[i].trim(); + /* Removes all double quotes at the start and end of an argument */ + /* This preserves all starting and ending whitespace inside */ + if (item.startsWith('"') && item.endsWith('"')) { + item = item.substr(1, item.length - 2); + } + argList[i] = item; + } + } -const getNamespace = function (name: string): NamespaceNode { - return namespaces.get(name)!; -}; + /* if no arguments passed into callback, default to passing in id */ + if (argList.length === 0) { + argList.push(elemId); + } -const getNamespaces = function (): NamespaceMap { - return namespaces; -}; - -/** - * Function called by parser when a namespace definition has been found. - * - * @param id - Id of the namespace to add - * @param classNames - Ids of the class to add - * @public - */ -export const addClassesToNamespace = function (id: string, classNames: string[]) { - if (!namespaces.has(id)) { - return; - } - for (const name of classNames) { - const { className } = splitClassNameAndType(name); - classes.get(className)!.parent = id; - namespaces.get(id)!.classes.set(className, classes.get(className)!); - } -}; - -export const setCssStyle = function (id: string, styles: string[]) { - const thisClass = classes.get(id); - if (!styles || !thisClass) { - return; - } - for (const s of styles) { - if (s.includes(',')) { - thisClass.styles.push(...s.split(',')); - } else { - thisClass.styles.push(s); + this.functions.push(() => { + const elem = document.querySelector(`[id="${elemId}"]`); + if (elem !== null) { + elem.addEventListener( + 'click', + () => { + utils.runFunc(functionName, ...argList); + }, + false + ); + } + }); } } -}; -/** - * Gets the arrow marker for a type index - * - * @param type - The type to look for - * @returns The arrow marker - */ -function getArrowMarker(type: number) { - let marker; - switch (type) { - case 0: - marker = 'aggregation'; - break; - case 1: - marker = 'extension'; - break; - case 2: - marker = 'composition'; - break; - case 3: - marker = 'dependency'; - break; - case 4: - marker = 'lollipop'; - break; - default: - marker = 'none'; + public bindFunctions(element: Element) { + this.functions.forEach((fun) => { + fun(element); + }); } - return marker; -} -export const getData = () => { - const nodes: Node[] = []; - const edges: Edge[] = []; - const config = getConfig(); + public readonly lineType = { + LINE: 0, + DOTTED_LINE: 1, + }; - for (const namespaceKey of namespaces.keys()) { - const namespace = namespaces.get(namespaceKey); - if (namespace) { - const node: Node = { - id: namespace.id, - label: namespace.id, - isGroup: true, - padding: config.class!.padding ?? 16, - // parent node must be one of [rect, roundedWithTitle, noteGroup, divider] - shape: 'rect', - cssStyles: ['fill: none', 'stroke: black'], + public readonly relationType = { + AGGREGATION: 0, + EXTENSION: 1, + COMPOSITION: 2, + DEPENDENCY: 3, + LOLLIPOP: 4, + }; + + private readonly setupToolTips = (element: Element) => { + let tooltipElem: Selection = + select('.mermaidTooltip'); + // @ts-expect-error - Incorrect types + if ((tooltipElem._groups || tooltipElem)[0][0] === null) { + tooltipElem = select('body') + .append('div') + .attr('class', 'mermaidTooltip') + .style('opacity', 0); + } + + const svg = select(element).select('svg'); + + const nodes = svg.selectAll('g.node'); + nodes + .on('mouseover', (event: MouseEvent) => { + const el = select(event.currentTarget as HTMLElement); + const title = el.attr('title'); + // Don't try to draw a tooltip if no data is provided + if (title === null) { + return; + } + // @ts-ignore - getBoundingClientRect is not part of the d3 type definition + const rect = this.getBoundingClientRect(); + + tooltipElem.transition().duration(200).style('opacity', '.9'); + tooltipElem + .text(el.attr('title')) + .style('left', window.scrollX + rect.left + (rect.right - rect.left) / 2 + 'px') + .style('top', window.scrollY + rect.top - 14 + document.body.scrollTop + 'px'); + tooltipElem.html(tooltipElem.html().replace(/<br\/>/g, '
')); + el.classed('hover', true); + }) + .on('mouseout', (event: MouseEvent) => { + tooltipElem.transition().duration(500).style('opacity', 0); + const el = select(event.currentTarget as HTMLElement); + el.classed('hover', false); + }); + }; + + private direction = 'TB'; + public getDirection() { + return this.direction; + } + public setDirection(dir: string) { + this.direction = dir; + } + + /** + * Function called by parser when a namespace definition has been found. + * + * @param id - Id of the namespace to add + * @public + */ + public addNamespace(id: string) { + if (this.namespaces.has(id)) { + return; + } + + this.namespaces.set(id, { + id: id, + classes: new Map(), + children: {}, + domId: MERMAID_DOM_ID_PREFIX + id + '-' + this.namespaceCounter, + } as NamespaceNode); + + this.namespaceCounter++; + } + + public getNamespace(name: string): NamespaceNode { + return this.namespaces.get(name)!; + } + + public getNamespaces(): NamespaceMap { + return this.namespaces; + } + + /** + * Function called by parser when a namespace definition has been found. + * + * @param id - Id of the namespace to add + * @param classNames - Ids of the class to add + * @public + */ + public addClassesToNamespace(id: string, classNames: string[]) { + if (!this.namespaces.has(id)) { + return; + } + for (const name of classNames) { + const { className } = this.splitClassNameAndType(name); + this.classes.get(className)!.parent = id; + this.namespaces.get(id)!.classes.set(className, this.classes.get(className)!); + } + } + + public setCssStyle(id: string, styles: string[]) { + const thisClass = this.classes.get(id); + if (!styles || !thisClass) { + return; + } + for (const s of styles) { + if (s.includes(',')) { + thisClass.styles.push(...s.split(',')); + } else { + thisClass.styles.push(s); + } + } + } + + /** + * Gets the arrow marker for a type index + * + * @param type - The type to look for + * @returns The arrow marker + */ + private getArrowMarker(type: number) { + let marker; + switch (type) { + case 0: + marker = 'aggregation'; + break; + case 1: + marker = 'extension'; + break; + case 2: + marker = 'composition'; + break; + case 3: + marker = 'dependency'; + break; + case 4: + marker = 'lollipop'; + break; + default: + marker = 'none'; + } + return marker; + } + + public getData() { + const nodes: Node[] = []; + const edges: Edge[] = []; + const config = getConfig(); + + for (const namespaceKey of this.namespaces.keys()) { + const namespace = this.namespaces.get(namespaceKey); + if (namespace) { + const node: Node = { + id: namespace.id, + label: namespace.id, + isGroup: true, + padding: config.class!.padding ?? 16, + // parent node must be one of [rect, roundedWithTitle, noteGroup, divider] + shape: 'rect', + cssStyles: ['fill: none', 'stroke: black'], + look: config.look, + }; + nodes.push(node); + } + } + + for (const classKey of this.classes.keys()) { + const classNode = this.classes.get(classKey); + if (classNode) { + const node = classNode as unknown as Node; + node.parentId = classNode.parent; + node.look = config.look; + nodes.push(node); + } + } + + let cnt = 0; + for (const note of this.notes) { + cnt++; + const noteNode: Node = { + id: note.id, + label: note.text, + isGroup: false, + shape: 'note', + padding: config.class!.padding ?? 6, + cssStyles: [ + 'text-align: left', + 'white-space: nowrap', + `fill: ${config.themeVariables.noteBkgColor}`, + `stroke: ${config.themeVariables.noteBorderColor}`, + ], look: config.look, }; - nodes.push(node); + nodes.push(noteNode); + + const noteClassId = this.classes.get(note.class)?.id ?? ''; + + if (noteClassId) { + const edge: Edge = { + id: `edgeNote${cnt}`, + start: note.id, + end: noteClassId, + type: 'normal', + thickness: 'normal', + classes: 'relation', + arrowTypeStart: 'none', + arrowTypeEnd: 'none', + arrowheadStyle: '', + labelStyle: [''], + style: ['fill: none'], + pattern: 'dotted', + look: config.look, + }; + edges.push(edge); + } } - } - for (const classKey of classes.keys()) { - const classNode = classes.get(classKey); - if (classNode) { - const node = classNode as unknown as Node; - node.parentId = classNode.parent; - node.look = config.look; - nodes.push(node); + for (const _interface of this.interfaces) { + const interfaceNode: Node = { + id: _interface.id, + label: _interface.label, + isGroup: false, + shape: 'rect', + cssStyles: ['opacity: 0;'], + look: config.look, + }; + nodes.push(interfaceNode); } - } - let cnt = 0; - for (const note of notes) { - cnt++; - const noteNode: Node = { - id: note.id, - label: note.text, - isGroup: false, - shape: 'note', - padding: config.class!.padding ?? 6, - cssStyles: [ - 'text-align: left', - 'white-space: nowrap', - `fill: ${config.themeVariables.noteBkgColor}`, - `stroke: ${config.themeVariables.noteBorderColor}`, - ], - look: config.look, - }; - nodes.push(noteNode); - - const noteClassId = classes.get(note.class)?.id ?? ''; - - if (noteClassId) { + cnt = 0; + for (const classRelation of this.relations) { + cnt++; const edge: Edge = { - id: `edgeNote${cnt}`, - start: note.id, - end: noteClassId, + id: getEdgeId(classRelation.id1, classRelation.id2, { + prefix: 'id', + counter: cnt, + }), + start: classRelation.id1, + end: classRelation.id2, type: 'normal', + label: classRelation.title, + labelpos: 'c', thickness: 'normal', classes: 'relation', - arrowTypeStart: 'none', - arrowTypeEnd: 'none', + arrowTypeStart: this.getArrowMarker(classRelation.relation.type1), + arrowTypeEnd: this.getArrowMarker(classRelation.relation.type2), + startLabelRight: + classRelation.relationTitle1 === 'none' ? '' : classRelation.relationTitle1, + endLabelLeft: classRelation.relationTitle2 === 'none' ? '' : classRelation.relationTitle2, arrowheadStyle: '', - labelStyle: [''], - style: ['fill: none'], - pattern: 'dotted', + labelStyle: ['display: inline-block'], + style: classRelation.style || '', + pattern: classRelation.relation.lineType == 1 ? 'dashed' : 'solid', look: config.look, }; edges.push(edge); } + + return { nodes, edges, other: {}, config, direction: this.getDirection() }; } - for (const _interface of interfaces) { - const interfaceNode: Node = { - id: _interface.id, - label: _interface.label, - isGroup: false, - shape: 'rect', - cssStyles: ['opacity: 0;'], - look: config.look, - }; - nodes.push(interfaceNode); - } - - cnt = 0; - for (const classRelation of relations) { - cnt++; - const edge: Edge = { - id: getEdgeId(classRelation.id1, classRelation.id2, { - prefix: 'id', - counter: cnt, - }), - start: classRelation.id1, - end: classRelation.id2, - type: 'normal', - label: classRelation.title, - labelpos: 'c', - thickness: 'normal', - classes: 'relation', - arrowTypeStart: getArrowMarker(classRelation.relation.type1), - arrowTypeEnd: getArrowMarker(classRelation.relation.type2), - startLabelRight: classRelation.relationTitle1 === 'none' ? '' : classRelation.relationTitle1, - endLabelLeft: classRelation.relationTitle2 === 'none' ? '' : classRelation.relationTitle2, - arrowheadStyle: '', - labelStyle: ['display: inline-block'], - style: classRelation.style || '', - pattern: classRelation.relation.lineType == 1 ? 'dashed' : 'solid', - look: config.look, - }; - edges.push(edge); - } - - return { nodes, edges, other: {}, config, direction: getDirection() }; -}; - -export default { - setAccTitle, - getAccTitle, - getAccDescription, - setAccDescription, - getConfig: () => getConfig().class, - addClass, - bindFunctions, - clear, - getClass, - getClasses, - getNotes, - addAnnotation, - addNote, - getRelations, - addRelation, - getDirection, - setDirection, - addMember, - addMembers, - cleanupLabel, - lineType, - relationType, - setClickEvent, - setCssClass, - defineClass, - setLink, - getTooltip, - setTooltip, - lookUpDomId, - setDiagramTitle, - getDiagramTitle, - setClassLabel, - addNamespace, - addClassesToNamespace, - getNamespace, - getNamespaces, - setCssStyle, - getData, -}; + public setAccTitle = setAccTitle; + public getAccTitle = getAccTitle; + public setAccDescription = setAccDescription; + public getAccDescription = getAccDescription; + public setDiagramTitle = setDiagramTitle; + public getDiagramTitle = getDiagramTitle; + public getConfig = () => getConfig().class; +} diff --git a/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js b/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js index 18bdaade5..71f322478 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js +++ b/packages/mermaid/src/diagrams/class/classDiagram-styles.spec.js @@ -1,9 +1,11 @@ import { parser } from './parser/classDiagram.jison'; -import classDb from './classDb.js'; +import { ClassDB } from './classDb.js'; describe('class diagram, ', function () { describe('when parsing data from a classDiagram it', function () { + let classDb; beforeEach(function () { + classDb = new ClassDB(); parser.yy = classDb; parser.yy.clear(); }); diff --git a/packages/mermaid/src/diagrams/class/classDiagram-v2.ts b/packages/mermaid/src/diagrams/class/classDiagram-v2.ts index 6a3747e41..9111fe658 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram-v2.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram-v2.ts @@ -1,13 +1,15 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; // @ts-ignore: JISON doesn't support types import parser from './parser/classDiagram.jison'; -import db from './classDb.js'; +import { ClassDB } from './classDb.js'; import styles from './styles.js'; import renderer from './classRenderer-v3-unified.js'; export const diagram: DiagramDefinition = { parser, - db, + get db() { + return new ClassDB(); + }, renderer, styles, init: (cnf) => { @@ -15,6 +17,5 @@ export const diagram: DiagramDefinition = { cnf.class = {}; } cnf.class.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute; - db.clear(); }, }; diff --git a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts index 40027f27e..35a37f903 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts @@ -1,6 +1,7 @@ +/* eslint-disable @typescript-eslint/unbound-method -- Broken for Vitest mocks, see https://github.com/vitest-dev/eslint-plugin-vitest/pull/286 */ // @ts-expect-error Jison doesn't export types import { parser } from './parser/classDiagram.jison'; -import classDb from './classDb.js'; +import { ClassDB } from './classDb.js'; import { vi, describe, it, expect } from 'vitest'; import type { ClassMap, NamespaceNode } from './classTypes.js'; const spyOn = vi.spyOn; @@ -10,8 +11,9 @@ const abstractCssStyle = 'font-style:italic;'; describe('given a basic class diagram, ', function () { describe('when parsing class definition', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); it('should handle classes within namespaces', () => { @@ -564,8 +566,9 @@ class C13["With Città foreign language"] }); describe('when parsing class defined in brackets', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -656,8 +659,9 @@ class C13["With Città foreign language"] }); describe('when parsing comments', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -746,8 +750,9 @@ foo() }); describe('when parsing click statements', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); it('should handle href link', function () { @@ -857,8 +862,9 @@ foo() }); describe('when parsing annotations', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -921,8 +927,9 @@ foo() describe('given a class diagram with members and methods ', function () { describe('when parsing members', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -980,8 +987,9 @@ describe('given a class diagram with members and methods ', function () { }); describe('when parsing method definition', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -1067,8 +1075,9 @@ describe('given a class diagram with members and methods ', function () { describe('given a class diagram with generics, ', function () { describe('when parsing valid generic classes', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -1180,8 +1189,9 @@ namespace space { describe('given a class diagram with relationships, ', function () { describe('when parsing basic relationships', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -1714,7 +1724,9 @@ class Class2 }); describe('when parsing classDiagram with text labels', () => { + let classDb: ClassDB; beforeEach(function () { + classDb = new ClassDB(); parser.yy = classDb; parser.yy.clear(); }); @@ -1897,3 +1909,40 @@ class C13["With Città foreign language"] }); }); }); + +describe('class db class', () => { + let classDb: ClassDB; + beforeEach(() => { + classDb = new ClassDB(); + }); + // This is to ensure that functions used in class JISON are exposed as function from ClassDB + it('should have functions used in class JISON as own property', () => { + const functionsUsedInParser = [ + 'addRelation', + 'cleanupLabel', + 'setAccTitle', + 'setAccDescription', + 'addClassesToNamespace', + 'addNamespace', + 'setCssClass', + 'addMembers', + 'addClass', + 'setClassLabel', + 'addAnnotation', + 'addMember', + 'addNote', + 'defineClass', + 'setDirection', + 'relationType', + 'lineType', + 'setClickEvent', + 'setTooltip', + 'setLink', + 'setCssStyle', + ] as const satisfies (keyof ClassDB)[]; + + for (const fun of functionsUsedInParser) { + expect(Object.hasOwn(classDb, fun)).toBe(true); + } + }); +}); diff --git a/packages/mermaid/src/diagrams/class/classDiagram.ts b/packages/mermaid/src/diagrams/class/classDiagram.ts index 6a3747e41..9111fe658 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.ts @@ -1,13 +1,15 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; // @ts-ignore: JISON doesn't support types import parser from './parser/classDiagram.jison'; -import db from './classDb.js'; +import { ClassDB } from './classDb.js'; import styles from './styles.js'; import renderer from './classRenderer-v3-unified.js'; export const diagram: DiagramDefinition = { parser, - db, + get db() { + return new ClassDB(); + }, renderer, styles, init: (cnf) => { @@ -15,6 +17,5 @@ export const diagram: DiagramDefinition = { cnf.class = {}; } cnf.class.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute; - db.clear(); }, }; diff --git a/packages/mermaid/src/diagrams/class/parser/class.spec.js b/packages/mermaid/src/diagrams/class/parser/class.spec.js index d611dfc02..fe0077a29 100644 --- a/packages/mermaid/src/diagrams/class/parser/class.spec.js +++ b/packages/mermaid/src/diagrams/class/parser/class.spec.js @@ -1,8 +1,10 @@ import { parser } from './classDiagram.jison'; -import classDb from '../classDb.js'; +import { ClassDB } from '../classDb.js'; describe('class diagram', function () { + let classDb; beforeEach(function () { + classDb = new ClassDB(); parser.yy = classDb; parser.yy.clear(); }); diff --git a/packages/mermaid/src/diagrams/state/parser/state-parser.spec.js b/packages/mermaid/src/diagrams/state/parser/state-parser.spec.js index 9fa8acab8..bb5345996 100644 --- a/packages/mermaid/src/diagrams/state/parser/state-parser.spec.js +++ b/packages/mermaid/src/diagrams/state/parser/state-parser.spec.js @@ -1,4 +1,4 @@ -import stateDb from '../stateDb.js'; +import { StateDB } from '../stateDb.js'; import stateDiagram from './stateDiagram.jison'; import { setConfig } from '../../../config.js'; @@ -7,7 +7,9 @@ setConfig({ }); describe('state parser can parse...', () => { + let stateDb; beforeEach(function () { + stateDb = new StateDB(); stateDiagram.parser.yy = stateDb; stateDiagram.parser.yy.clear(); }); diff --git a/packages/mermaid/src/diagrams/state/parser/state-style.spec.js b/packages/mermaid/src/diagrams/state/parser/state-style.spec.js index fed63c444..c37bed3c7 100644 --- a/packages/mermaid/src/diagrams/state/parser/state-style.spec.js +++ b/packages/mermaid/src/diagrams/state/parser/state-style.spec.js @@ -1,4 +1,4 @@ -import stateDb from '../stateDb.js'; +import { StateDB } from '../stateDb.js'; import stateDiagram from './stateDiagram.jison'; import { setConfig } from '../../../config.js'; @@ -7,7 +7,9 @@ setConfig({ }); describe('ClassDefs and classes when parsing a State diagram', () => { + let stateDb; beforeEach(function () { + stateDb = new StateDB(); stateDiagram.parser.yy = stateDb; stateDiagram.parser.yy.clear(); }); diff --git a/packages/mermaid/src/diagrams/state/shapes.js b/packages/mermaid/src/diagrams/state/shapes.js index f0ab4136b..b18b4ca0e 100644 --- a/packages/mermaid/src/diagrams/state/shapes.js +++ b/packages/mermaid/src/diagrams/state/shapes.js @@ -1,6 +1,6 @@ import { line, curveBasis } from 'd3'; import idCache from './id-cache.js'; -import stateDb from './stateDb.js'; +import { StateDB } from './stateDb.js'; import utils from '../../utils.js'; import common from '../common/common.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; @@ -414,13 +414,13 @@ let edgeCount = 0; export const drawEdge = function (elem, path, relation) { const getRelationType = function (type) { switch (type) { - case stateDb.relationType.AGGREGATION: + case StateDB.relationType.AGGREGATION: return 'aggregation'; - case stateDb.relationType.EXTENSION: + case StateDB.relationType.EXTENSION: return 'extension'; - case stateDb.relationType.COMPOSITION: + case StateDB.relationType.COMPOSITION: return 'composition'; - case stateDb.relationType.DEPENDENCY: + case StateDB.relationType.DEPENDENCY: return 'dependency'; } }; @@ -459,7 +459,7 @@ export const drawEdge = function (elem, path, relation) { svgPath.attr( 'marker-end', - 'url(' + url + '#' + getRelationType(stateDb.relationType.DEPENDENCY) + 'End' + ')' + 'url(' + url + '#' + getRelationType(StateDB.relationType.DEPENDENCY) + 'End' + ')' ); if (relation.title !== undefined) { diff --git a/packages/mermaid/src/diagrams/state/stateDb.js b/packages/mermaid/src/diagrams/state/stateDb.js index c66465941..0d8ed80f4 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.js +++ b/packages/mermaid/src/diagrams/state/stateDb.js @@ -1,28 +1,28 @@ +import { getConfig } from '../../diagram-api/diagramAPI.js'; import { log } from '../../logger.js'; import { generateId } from '../../utils.js'; import common from '../common/common.js'; -import { getConfig } from '../../diagram-api/diagramAPI.js'; import { - setAccTitle, - getAccTitle, - getAccDescription, - setAccDescription, clear as commonClear, - setDiagramTitle, + getAccDescription, + getAccTitle, getDiagramTitle, + setAccDescription, + setAccTitle, + setDiagramTitle, } from '../common/commonDb.js'; import { dataFetcher, reset as resetDataFetching } from './dataFetcher.js'; import { getDir } from './stateRenderer-v3-unified.js'; import { DEFAULT_DIAGRAM_DIRECTION, - STMT_STATE, - STMT_RELATION, - STMT_CLASSDEF, - STMT_STYLEDEF, - STMT_APPLYCLASS, DEFAULT_STATE_TYPE, DIVIDER_TYPE, + STMT_APPLYCLASS, + STMT_CLASSDEF, + STMT_RELATION, + STMT_STATE, + STMT_STYLEDEF, } from './stateCommon.js'; const START_NODE = '[*]'; @@ -46,15 +46,6 @@ function newClassesList() { return new Map(); } -let nodes = []; -let edges = []; - -let direction = DEFAULT_DIAGRAM_DIRECTION; -let rootDoc = []; -let classes = newClassesList(); // style classes defined by a classDef - -// -------------------------------------- - const newDoc = () => { return { /** @type {{ id1: string, id2: string, relationTitle: string }[]} */ @@ -63,564 +54,623 @@ const newDoc = () => { documents: {}, }; }; -let documents = { - root: newDoc(), -}; - -let currentDocument = documents.root; -let startEndCount = 0; -let dividerCnt = 0; - -export const lineType = { - LINE: 0, - DOTTED_LINE: 1, -}; - -export const relationType = { - AGGREGATION: 0, - EXTENSION: 1, - COMPOSITION: 2, - DEPENDENCY: 3, -}; const clone = (o) => JSON.parse(JSON.stringify(o)); -const setRootDoc = (o) => { - log.info('Setting root doc', o); - // rootDoc = { id: 'root', doc: o }; - rootDoc = o; -}; +export class StateDB { + constructor() { + this.clear(); -const getRootDoc = () => rootDoc; - -const docTranslator = (parent, node, first) => { - if (node.stmt === STMT_RELATION) { - docTranslator(parent, node.state1, true); - docTranslator(parent, node.state2, false); - } else { - if (node.stmt === STMT_STATE) { - if (node.id === '[*]') { - node.id = first ? parent.id + '_start' : parent.id + '_end'; - node.start = first; - } else { - // This is just a plain state, not a start or end - node.id = node.id.trim(); - } - } - - if (node.doc) { - const doc = []; - // Check for concurrency - let currentDoc = []; - let i; - for (i = 0; i < node.doc.length; i++) { - if (node.doc[i].type === DIVIDER_TYPE) { - // debugger; - const newNode = clone(node.doc[i]); - newNode.doc = clone(currentDoc); - doc.push(newNode); - currentDoc = []; - } else { - currentDoc.push(node.doc[i]); - } - } - - // If any divider was encountered - if (doc.length > 0 && currentDoc.length > 0) { - const newNode = { - stmt: STMT_STATE, - id: generateId(), - type: 'divider', - doc: clone(currentDoc), - }; - doc.push(clone(newNode)); - node.doc = doc; - } - - node.doc.forEach((docNode) => docTranslator(node, docNode, true)); - } - } -}; -const getRootDocV2 = () => { - docTranslator({ id: 'root' }, { id: 'root', doc: rootDoc }, true); - return { id: 'root', doc: rootDoc }; - // Here -}; - -/** - * Convert all of the statements (stmts) that were parsed into states and relationships. - * This is done because a state diagram may have nested sections, - * where each section is a 'document' and has its own set of statements. - * Ex: the section within a fork has its own statements, and incoming and outgoing statements - * refer to the fork as a whole (document). - * See the parser grammar: the definition of a document is a document then a 'line', where a line can be a statement. - * This will push the statement into the list of statements for the current document. - * - * @param _doc - */ -const extract = (_doc) => { - // const res = { states: [], relations: [] }; - let doc; - if (_doc.doc) { - doc = _doc.doc; - } else { - doc = _doc; - } - // let doc = root.doc; - // if (!doc) { - // doc = root; - // } - log.info(doc); - clear(true); - - log.info('Extract initial document:', doc); - - doc.forEach((item) => { - log.warn('Statement', item.stmt); - switch (item.stmt) { - case STMT_STATE: - addState( - item.id.trim(), - item.type, - item.doc, - item.description, - item.note, - item.classes, - item.styles, - item.textStyles - ); - break; - case STMT_RELATION: - addRelation(item.state1, item.state2, item.description); - break; - case STMT_CLASSDEF: - addStyleClass(item.id.trim(), item.classes); - break; - case STMT_STYLEDEF: - { - const ids = item.id.trim().split(','); - const styles = item.styleClass.split(','); - ids.forEach((id) => { - let foundState = getState(id); - if (foundState === undefined) { - const trimmedId = id.trim(); - addState(trimmedId); - foundState = getState(trimmedId); - } - foundState.styles = styles.map((s) => s.replace(/;/g, '')?.trim()); - }); - } - break; - case STMT_APPLYCLASS: - setCssClass(item.id.trim(), item.styleClass); - break; - } - }); - - const diagramStates = getStates(); - const config = getConfig(); - const look = config.look; - resetDataFetching(); - dataFetcher(undefined, getRootDocV2(), diagramStates, nodes, edges, true, look, classes, config); - nodes.forEach((node) => { - if (Array.isArray(node.label)) { - // add the rest as description - node.description = node.label.slice(1); - if (node.isGroup && node.description.length > 0) { - throw new Error( - 'Group nodes can only have label. Remove the additional description for node [' + - node.id + - ']' - ); - } - // add first description as label - node.label = node.label[0]; - } - }); -}; - -/** - * Function called by parser when a node definition has been found. - * - * @param {null | string} id - * @param {null | string} type - * @param {null | string} doc - * @param {null | string | string[]} descr - description for the state. Can be a string or a list or strings - * @param {null | string} note - * @param {null | string | string[]} classes - class styles to apply to this state. Can be a string (1 style) or an array of styles. If it's just 1 class, convert it to an array of that 1 class. - * @param {null | string | string[]} styles - styles to apply to this state. Can be a string (1 style) or an array of styles. If it's just 1 style, convert it to an array of that 1 style. - * @param {null | string | string[]} textStyles - text styles to apply to this state. Can be a string (1 text test) or an array of text styles. If it's just 1 text style, convert it to an array of that 1 text style. - */ -export const addState = function ( - id, - type = DEFAULT_STATE_TYPE, - doc = null, - descr = null, - note = null, - classes = null, - styles = null, - textStyles = null -) { - const trimmedId = id?.trim(); - // add the state if needed - if (!currentDocument.states.has(trimmedId)) { - log.info('Adding state ', trimmedId, descr); - currentDocument.states.set(trimmedId, { - id: trimmedId, - descriptions: [], - type, - doc, - note, - classes: [], - styles: [], - textStyles: [], - }); - } else { - if (!currentDocument.states.get(trimmedId).doc) { - currentDocument.states.get(trimmedId).doc = doc; - } - if (!currentDocument.states.get(trimmedId).type) { - currentDocument.states.get(trimmedId).type = type; - } + // Needed for JISON since it only supports direct properties + this.setRootDoc = this.setRootDoc.bind(this); + this.getDividerId = this.getDividerId.bind(this); + this.setDirection = this.setDirection.bind(this); + this.trimColon = this.trimColon.bind(this); } - if (descr) { - log.info('Setting state description', trimmedId, descr); - if (typeof descr === 'string') { - addDescription(trimmedId, descr.trim()); - } - - if (typeof descr === 'object') { - descr.forEach((des) => addDescription(trimmedId, des.trim())); - } - } - - if (note) { - const doc2 = currentDocument.states.get(trimmedId); - doc2.note = note; - doc2.note.text = common.sanitizeText(doc2.note.text, getConfig()); - } - - if (classes) { - log.info('Setting state classes', trimmedId, classes); - const classesList = typeof classes === 'string' ? [classes] : classes; - classesList.forEach((cssClass) => setCssClass(trimmedId, cssClass.trim())); - } - - if (styles) { - log.info('Setting state styles', trimmedId, styles); - const stylesList = typeof styles === 'string' ? [styles] : styles; - stylesList.forEach((style) => setStyle(trimmedId, style.trim())); - } - - if (textStyles) { - log.info('Setting state styles', trimmedId, styles); - const textStylesList = typeof textStyles === 'string' ? [textStyles] : textStyles; - textStylesList.forEach((textStyle) => setTextStyle(trimmedId, textStyle.trim())); - } -}; - -export const clear = function (saveCommon) { + /** + * @private + * @type {Array} + */ nodes = []; + /** + * @private + * @type {Array} + */ edges = []; + + /** + * @private + * @type {string} + */ + direction = DEFAULT_DIAGRAM_DIRECTION; + /** + * @private + * @type {Array} + */ + rootDoc = []; + /** + * @private + * @type {Map} + */ + classes = newClassesList(); // style classes defined by a classDef + + /** + * @private + * @type {Object} + */ documents = { root: newDoc(), }; - currentDocument = documents.root; - // number of start and end nodes; used to construct ids + /** + * @private + * @type {Object} + */ + currentDocument = this.documents.root; + /** + * @private + * @type {number} + */ startEndCount = 0; - classes = newClassesList(); - if (!saveCommon) { - commonClear(); + /** + * @private + * @type {number} + */ + dividerCnt = 0; + + static relationType = { + AGGREGATION: 0, + EXTENSION: 1, + COMPOSITION: 2, + DEPENDENCY: 3, + }; + + setRootDoc(o) { + log.info('Setting root doc', o); + // rootDoc = { id: 'root', doc: o }; + this.rootDoc = o; } -}; -export const getState = function (id) { - return currentDocument.states.get(id); -}; - -export const getStates = function () { - return currentDocument.states; -}; -export const logDocuments = function () { - log.info('Documents = ', documents); -}; -export const getRelations = function () { - return currentDocument.relations; -}; - -/** - * If the id is a start node ( [*] ), then return a new id constructed from - * the start node name and the current start node count. - * else return the given id - * - * @param {string} id - * @returns {string} - the id (original or constructed) - */ -function startIdIfNeeded(id = '') { - let fixedId = id; - if (id === START_NODE) { - startEndCount++; - fixedId = `${START_TYPE}${startEndCount}`; + getRootDoc() { + return this.rootDoc; } - return fixedId; -} -/** - * If the id is a start node ( [*] ), then return the start type ('start') - * else return the given type - * - * @param {string} id - * @param {string} type - * @returns {string} - the type that should be used - */ -function startTypeIfNeeded(id = '', type = DEFAULT_STATE_TYPE) { - return id === START_NODE ? START_TYPE : type; -} + /** + * @private + * @param {Object} parent + * @param {Object} node + * @param {boolean} first + */ + docTranslator(parent, node, first) { + if (node.stmt === STMT_RELATION) { + this.docTranslator(parent, node.state1, true); + this.docTranslator(parent, node.state2, false); + } else { + if (node.stmt === STMT_STATE) { + if (node.id === '[*]') { + node.id = first ? parent.id + '_start' : parent.id + '_end'; + node.start = first; + } else { + // This is just a plain state, not a start or end + node.id = node.id.trim(); + } + } -/** - * If the id is an end node ( [*] ), then return a new id constructed from - * the end node name and the current start_end node count. - * else return the given id - * - * @param {string} id - * @returns {string} - the id (original or constructed) - */ -function endIdIfNeeded(id = '') { - let fixedId = id; - if (id === END_NODE) { - startEndCount++; - fixedId = `${END_TYPE}${startEndCount}`; + if (node.doc) { + const doc = []; + // Check for concurrency + let currentDoc = []; + let i; + for (i = 0; i < node.doc.length; i++) { + if (node.doc[i].type === DIVIDER_TYPE) { + const newNode = clone(node.doc[i]); + newNode.doc = clone(currentDoc); + doc.push(newNode); + currentDoc = []; + } else { + currentDoc.push(node.doc[i]); + } + } + + // If any divider was encountered + if (doc.length > 0 && currentDoc.length > 0) { + const newNode = { + stmt: STMT_STATE, + id: generateId(), + type: 'divider', + doc: clone(currentDoc), + }; + doc.push(clone(newNode)); + node.doc = doc; + } + + node.doc.forEach((docNode) => this.docTranslator(node, docNode, true)); + } + } + } + getRootDocV2() { + this.docTranslator({ id: 'root' }, { id: 'root', doc: this.rootDoc }, true); + return { id: 'root', doc: this.rootDoc }; + // Here } - return fixedId; -} -/** - * If the id is an end node ( [*] ), then return the end type - * else return the given type - * - * @param {string} id - * @param {string} type - * @returns {string} - the type that should be used - */ -function endTypeIfNeeded(id = '', type = DEFAULT_STATE_TYPE) { - return id === END_NODE ? END_TYPE : type; -} + /** + * Convert all of the statements (stmts) that were parsed into states and relationships. + * This is done because a state diagram may have nested sections, + * where each section is a 'document' and has its own set of statements. + * Ex: the section within a fork has its own statements, and incoming and outgoing statements + * refer to the fork as a whole (document). + * See the parser grammar: the definition of a document is a document then a 'line', where a line can be a statement. + * This will push the statement into the list of statements for the current document. + * + * @param _doc + */ + extract(_doc) { + // const res = { states: [], relations: [] }; + let doc; + if (_doc.doc) { + doc = _doc.doc; + } else { + doc = _doc; + } + // let doc = root.doc; + // if (!doc) { + // doc = root; + // } + log.info(doc); + this.clear(true); -/** - * - * @param item1 - * @param item2 - * @param relationTitle - */ -export function addRelationObjs(item1, item2, relationTitle) { - let id1 = startIdIfNeeded(item1.id.trim()); - let type1 = startTypeIfNeeded(item1.id.trim(), item1.type); - let id2 = startIdIfNeeded(item2.id.trim()); - let type2 = startTypeIfNeeded(item2.id.trim(), item2.type); + log.info('Extract initial document:', doc); + doc.forEach((item) => { + log.warn('Statement', item.stmt); + switch (item.stmt) { + case STMT_STATE: + this.addState( + item.id.trim(), + item.type, + item.doc, + item.description, + item.note, + item.classes, + item.styles, + item.textStyles + ); + break; + case STMT_RELATION: + this.addRelation(item.state1, item.state2, item.description); + break; + case STMT_CLASSDEF: + this.addStyleClass(item.id.trim(), item.classes); + break; + case STMT_STYLEDEF: + { + const ids = item.id.trim().split(','); + const styles = item.styleClass.split(','); + ids.forEach((id) => { + let foundState = this.getState(id); + if (foundState === undefined) { + const trimmedId = id.trim(); + this.addState(trimmedId); + foundState = this.getState(trimmedId); + } + foundState.styles = styles.map((s) => s.replace(/;/g, '')?.trim()); + }); + } + break; + case STMT_APPLYCLASS: + this.setCssClass(item.id.trim(), item.styleClass); + break; + } + }); + + const diagramStates = this.getStates(); + const config = getConfig(); + const look = config.look; + + resetDataFetching(); + dataFetcher( + undefined, + this.getRootDocV2(), + diagramStates, + this.nodes, + this.edges, + true, + look, + this.classes, + config + ); + this.nodes.forEach((node) => { + if (Array.isArray(node.label)) { + // add the rest as description + node.description = node.label.slice(1); + if (node.isGroup && node.description.length > 0) { + throw new Error( + 'Group nodes can only have label. Remove the additional description for node [' + + node.id + + ']' + ); + } + // add first description as label + node.label = node.label[0]; + } + }); + } + + /** + * Function called by parser when a node definition has been found. + * + * @param {null | string} id + * @param {null | string} type + * @param {null | string} doc + * @param {null | string | string[]} descr - description for the state. Can be a string or a list or strings + * @param {null | string} note + * @param {null | string | string[]} classes - class styles to apply to this state. Can be a string (1 style) or an array of styles. If it's just 1 class, convert it to an array of that 1 class. + * @param {null | string | string[]} styles - styles to apply to this state. Can be a string (1 style) or an array of styles. If it's just 1 style, convert it to an array of that 1 style. + * @param {null | string | string[]} textStyles - text styles to apply to this state. Can be a string (1 text test) or an array of text styles. If it's just 1 text style, convert it to an array of that 1 text style. + */ addState( - id1, - type1, - item1.doc, - item1.description, - item1.note, - item1.classes, - item1.styles, - item1.textStyles - ); - addState( - id2, - type2, - item2.doc, - item2.description, - item2.note, - item2.classes, - item2.styles, - item2.textStyles - ); + id, + type = DEFAULT_STATE_TYPE, + doc = null, + descr = null, + note = null, + classes = null, + styles = null, + textStyles = null + ) { + const trimmedId = id?.trim(); + // add the state if needed + if (!this.currentDocument.states.has(trimmedId)) { + log.info('Adding state ', trimmedId, descr); + this.currentDocument.states.set(trimmedId, { + id: trimmedId, + descriptions: [], + type, + doc, + note, + classes: [], + styles: [], + textStyles: [], + }); + } else { + if (!this.currentDocument.states.get(trimmedId).doc) { + this.currentDocument.states.get(trimmedId).doc = doc; + } + if (!this.currentDocument.states.get(trimmedId).type) { + this.currentDocument.states.get(trimmedId).type = type; + } + } - currentDocument.relations.push({ - id1, - id2, - relationTitle: common.sanitizeText(relationTitle, getConfig()), - }); -} + if (descr) { + log.info('Setting state description', trimmedId, descr); + if (typeof descr === 'string') { + this.addDescription(trimmedId, descr.trim()); + } -/** - * Add a relation between two items. The items may be full objects or just the string id of a state. - * - * @param {string | object} item1 - * @param {string | object} item2 - * @param {string} title - */ -export const addRelation = function (item1, item2, title) { - if (typeof item1 === 'object') { - addRelationObjs(item1, item2, title); - } else { - const id1 = startIdIfNeeded(item1.trim()); - const type1 = startTypeIfNeeded(item1); - const id2 = endIdIfNeeded(item2.trim()); - const type2 = endTypeIfNeeded(item2); + if (typeof descr === 'object') { + descr.forEach((des) => this.addDescription(trimmedId, des.trim())); + } + } - addState(id1, type1); - addState(id2, type2); - currentDocument.relations.push({ + if (note) { + const doc2 = this.currentDocument.states.get(trimmedId); + doc2.note = note; + doc2.note.text = common.sanitizeText(doc2.note.text, getConfig()); + } + + if (classes) { + log.info('Setting state classes', trimmedId, classes); + const classesList = typeof classes === 'string' ? [classes] : classes; + classesList.forEach((cssClass) => this.setCssClass(trimmedId, cssClass.trim())); + } + + if (styles) { + log.info('Setting state styles', trimmedId, styles); + const stylesList = typeof styles === 'string' ? [styles] : styles; + stylesList.forEach((style) => this.setStyle(trimmedId, style.trim())); + } + + if (textStyles) { + log.info('Setting state styles', trimmedId, styles); + const textStylesList = typeof textStyles === 'string' ? [textStyles] : textStyles; + textStylesList.forEach((textStyle) => this.setTextStyle(trimmedId, textStyle.trim())); + } + } + + clear(saveCommon) { + this.nodes = []; + this.edges = []; + this.documents = { + root: newDoc(), + }; + this.currentDocument = this.documents.root; + + // number of start and end nodes; used to construct ids + this.startEndCount = 0; + this.classes = newClassesList(); + if (!saveCommon) { + commonClear(); + } + } + + getState(id) { + return this.currentDocument.states.get(id); + } + getStates() { + return this.currentDocument.states; + } + logDocuments() { + log.info('Documents = ', this.documents); + } + getRelations() { + return this.currentDocument.relations; + } + + /** + * If the id is a start node ( [*] ), then return a new id constructed from + * the start node name and the current start node count. + * else return the given id + * + * @param {string} id + * @returns {string} - the id (original or constructed) + * @private + */ + startIdIfNeeded(id = '') { + let fixedId = id; + if (id === START_NODE) { + this.startEndCount++; + fixedId = `${START_TYPE}${this.startEndCount}`; + } + return fixedId; + } + + /** + * If the id is a start node ( [*] ), then return the start type ('start') + * else return the given type + * + * @param {string} id + * @param {string} type + * @returns {string} - the type that should be used + * @private + */ + startTypeIfNeeded(id = '', type = DEFAULT_STATE_TYPE) { + return id === START_NODE ? START_TYPE : type; + } + + /** + * If the id is an end node ( [*] ), then return a new id constructed from + * the end node name and the current start_end node count. + * else return the given id + * + * @param {string} id + * @returns {string} - the id (original or constructed) + * @private + */ + endIdIfNeeded(id = '') { + let fixedId = id; + if (id === END_NODE) { + this.startEndCount++; + fixedId = `${END_TYPE}${this.startEndCount}`; + } + return fixedId; + } + + /** + * If the id is an end node ( [*] ), then return the end type + * else return the given type + * + * @param {string} id + * @param {string} type + * @returns {string} - the type that should be used + * @private + */ + endTypeIfNeeded(id = '', type = DEFAULT_STATE_TYPE) { + return id === END_NODE ? END_TYPE : type; + } + + /** + * + * @param item1 + * @param item2 + * @param relationTitle + */ + addRelationObjs(item1, item2, relationTitle) { + let id1 = this.startIdIfNeeded(item1.id.trim()); + let type1 = this.startTypeIfNeeded(item1.id.trim(), item1.type); + let id2 = this.startIdIfNeeded(item2.id.trim()); + let type2 = this.startTypeIfNeeded(item2.id.trim(), item2.type); + + this.addState( + id1, + type1, + item1.doc, + item1.description, + item1.note, + item1.classes, + item1.styles, + item1.textStyles + ); + this.addState( + id2, + type2, + item2.doc, + item2.description, + item2.note, + item2.classes, + item2.styles, + item2.textStyles + ); + + this.currentDocument.relations.push({ id1, id2, - title: common.sanitizeText(title, getConfig()), + relationTitle: common.sanitizeText(relationTitle, getConfig()), }); } -}; -export const addDescription = function (id, descr) { - const theState = currentDocument.states.get(id); - const _descr = descr.startsWith(':') ? descr.replace(':', '').trim() : descr; - theState.descriptions.push(common.sanitizeText(_descr, getConfig())); -}; + /** + * Add a relation between two items. The items may be full objects or just the string id of a state. + * + * @param {string | object} item1 + * @param {string | object} item2 + * @param {string} title + */ + addRelation(item1, item2, title) { + if (typeof item1 === 'object') { + this.addRelationObjs(item1, item2, title); + } else { + const id1 = this.startIdIfNeeded(item1.trim()); + const type1 = this.startTypeIfNeeded(item1); + const id2 = this.endIdIfNeeded(item2.trim()); + const type2 = this.endTypeIfNeeded(item2); -export const cleanupLabel = function (label) { - if (label.substring(0, 1) === ':') { - return label.substr(2).trim(); - } else { - return label.trim(); - } -}; - -const getDividerId = () => { - dividerCnt++; - return 'divider-id-' + dividerCnt; -}; - -/** - * Called when the parser comes across a (style) class definition - * @example classDef my-style fill:#f96; - * - * @param {string} id - the id of this (style) class - * @param {string | null} styleAttributes - the string with 1 or more style attributes (each separated by a comma) - */ -export const addStyleClass = function (id, styleAttributes = '') { - // create a new style class object with this id - if (!classes.has(id)) { - classes.set(id, { id: id, styles: [], textStyles: [] }); // This is a classDef - } - const foundClass = classes.get(id); - if (styleAttributes !== undefined && styleAttributes !== null) { - styleAttributes.split(STYLECLASS_SEP).forEach((attrib) => { - // remove any trailing ; - const fixedAttrib = attrib.replace(/([^;]*);/, '$1').trim(); - - // replace some style keywords - if (RegExp(COLOR_KEYWORD).exec(attrib)) { - const newStyle1 = fixedAttrib.replace(FILL_KEYWORD, BG_FILL); - const newStyle2 = newStyle1.replace(COLOR_KEYWORD, FILL_KEYWORD); - foundClass.textStyles.push(newStyle2); - } - foundClass.styles.push(fixedAttrib); - }); - } -}; - -/** - * Return all of the style classes - * @returns {{} | any | classes} - */ -export const getClasses = function () { - return classes; -}; - -/** - * Add a (style) class or css class to a state with the given id. - * If the state isn't already in the list of known states, add it. - * Might be called by parser when a style class or CSS class should be applied to a state - * - * @param {string | string[]} itemIds The id or a list of ids of the item(s) to apply the css class to - * @param {string} cssClassName CSS class name - */ -export const setCssClass = function (itemIds, cssClassName) { - itemIds.split(',').forEach(function (id) { - let foundState = getState(id); - if (foundState === undefined) { - const trimmedId = id.trim(); - addState(trimmedId); - foundState = getState(trimmedId); + this.addState(id1, type1); + this.addState(id2, type2); + this.currentDocument.relations.push({ + id1, + id2, + title: common.sanitizeText(title, getConfig()), + }); } - foundState.classes.push(cssClassName); - }); -}; - -/** - * Add a style to a state with the given id. - * @example style stateId fill:#f9f,stroke:#333,stroke-width:4px - * where 'style' is the keyword - * stateId is the id of a state - * the rest of the string is the styleText (all of the attributes to be applied to the state) - * - * @param itemId The id of item to apply the style to - * @param styleText - the text of the attributes for the style - */ -export const setStyle = function (itemId, styleText) { - const item = getState(itemId); - if (item !== undefined) { - item.styles.push(styleText); } -}; -/** - * Add a text style to a state with the given id - * - * @param itemId The id of item to apply the css class to - * @param cssClassName CSS class name - */ -export const setTextStyle = function (itemId, cssClassName) { - const item = getState(itemId); - if (item !== undefined) { - item.textStyles.push(cssClassName); + addDescription(id, descr) { + const theState = this.currentDocument.states.get(id); + const _descr = descr.startsWith(':') ? descr.replace(':', '').trim() : descr; + theState.descriptions.push(common.sanitizeText(_descr, getConfig())); } -}; -const getDirection = () => direction; -const setDirection = (dir) => { - direction = dir; -}; + cleanupLabel(label) { + if (label.substring(0, 1) === ':') { + return label.substr(2).trim(); + } else { + return label.trim(); + } + } -const trimColon = (str) => (str && str[0] === ':' ? str.substr(1).trim() : str.trim()); + getDividerId() { + this.dividerCnt++; + return 'divider-id-' + this.dividerCnt; + } -export const getData = () => { - const config = getConfig(); - return { nodes, edges, other: {}, config, direction: getDir(getRootDocV2()) }; -}; + /** + * Called when the parser comes across a (style) class definition + * @example classDef my-style fill:#f96; + * + * @param {string} id - the id of this (style) class + * @param {string | null} styleAttributes - the string with 1 or more style attributes (each separated by a comma) + */ + addStyleClass(id, styleAttributes = '') { + // create a new style class object with this id + if (!this.classes.has(id)) { + this.classes.set(id, { id: id, styles: [], textStyles: [] }); // This is a classDef + } + const foundClass = this.classes.get(id); + if (styleAttributes !== undefined && styleAttributes !== null) { + styleAttributes.split(STYLECLASS_SEP).forEach((attrib) => { + // remove any trailing ; + const fixedAttrib = attrib.replace(/([^;]*);/, '$1').trim(); -export default { - getConfig: () => getConfig().state, - getData, - addState, - clear, - getState, - getStates, - getRelations, - getClasses, - getDirection, - addRelation, - getDividerId, - setDirection, - cleanupLabel, - lineType, - relationType, - logDocuments, - getRootDoc, - setRootDoc, - getRootDocV2, - extract, - trimColon, - getAccTitle, - setAccTitle, - getAccDescription, - setAccDescription, - addStyleClass, - setCssClass, - addDescription, - setDiagramTitle, - getDiagramTitle, -}; + // replace some style keywords + if (RegExp(COLOR_KEYWORD).exec(attrib)) { + const newStyle1 = fixedAttrib.replace(FILL_KEYWORD, BG_FILL); + const newStyle2 = newStyle1.replace(COLOR_KEYWORD, FILL_KEYWORD); + foundClass.textStyles.push(newStyle2); + } + foundClass.styles.push(fixedAttrib); + }); + } + } + + /** + * Return all of the style classes + * @returns {{} | any | classes} + */ + getClasses() { + return this.classes; + } + + /** + * Add a (style) class or css class to a state with the given id. + * If the state isn't already in the list of known states, add it. + * Might be called by parser when a style class or CSS class should be applied to a state + * + * @param {string | string[]} itemIds The id or a list of ids of the item(s) to apply the css class to + * @param {string} cssClassName CSS class name + */ + setCssClass(itemIds, cssClassName) { + itemIds.split(',').forEach((id) => { + let foundState = this.getState(id); + if (foundState === undefined) { + const trimmedId = id.trim(); + this.addState(trimmedId); + foundState = this.getState(trimmedId); + } + foundState.classes.push(cssClassName); + }); + } + + /** + * Add a style to a state with the given id. + * @example style stateId fill:#f9f,stroke:#333,stroke-width:4px + * where 'style' is the keyword + * stateId is the id of a state + * the rest of the string is the styleText (all of the attributes to be applied to the state) + * + * @param itemId The id of item to apply the style to + * @param styleText - the text of the attributes for the style + */ + setStyle(itemId, styleText) { + const item = this.getState(itemId); + if (item !== undefined) { + item.styles.push(styleText); + } + } + + /** + * Add a text style to a state with the given id + * + * @param itemId The id of item to apply the css class to + * @param cssClassName CSS class name + */ + setTextStyle(itemId, cssClassName) { + const item = this.getState(itemId); + if (item !== undefined) { + item.textStyles.push(cssClassName); + } + } + + getDirection() { + return this.direction; + } + setDirection(dir) { + this.direction = dir; + } + + trimColon(str) { + return str && str[0] === ':' ? str.substr(1).trim() : str.trim(); + } + + getData() { + const config = getConfig(); + return { + nodes: this.nodes, + edges: this.edges, + other: {}, + config, + direction: getDir(this.getRootDocV2()), + }; + } + + getConfig() { + return getConfig().state; + } + getAccTitle = getAccTitle; + setAccTitle = setAccTitle; + getAccDescription = getAccDescription; + setAccDescription = setAccDescription; + setDiagramTitle = setDiagramTitle; + getDiagramTitle = getDiagramTitle; +} diff --git a/packages/mermaid/src/diagrams/state/stateDb.spec.js b/packages/mermaid/src/diagrams/state/stateDb.spec.js index ff0581200..73f1a4be9 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.spec.js +++ b/packages/mermaid/src/diagrams/state/stateDb.spec.js @@ -1,8 +1,9 @@ -import stateDb from './stateDb.js'; +import { StateDB } from './stateDb.js'; describe('State Diagram stateDb', () => { + let stateDb; beforeEach(() => { - stateDb.clear(); + stateDb = new StateDB(); }); describe('addStyleClass', () => { @@ -20,8 +21,9 @@ describe('State Diagram stateDb', () => { }); describe('addDescription to a state', () => { + let stateDb; beforeEach(() => { - stateDb.clear(); + stateDb = new StateDB(); stateDb.addState('state1'); }); @@ -73,3 +75,25 @@ describe('State Diagram stateDb', () => { }); }); }); + +describe('state db class', () => { + let stateDb; + beforeEach(() => { + stateDb = new StateDB(); + }); + // This is to ensure that functions used in state JISON are exposed as function from StateDb + it('should have functions used in flow JISON as own property', () => { + const functionsUsedInParser = [ + 'setRootDoc', + 'trimColon', + 'getDividerId', + 'setAccTitle', + 'setAccDescription', + 'setDirection', + ]; + + for (const fun of functionsUsedInParser) { + expect(Object.hasOwn(stateDb, fun)).toBe(true); + } + }); +}); diff --git a/packages/mermaid/src/diagrams/state/stateDiagram-v2.spec.js b/packages/mermaid/src/diagrams/state/stateDiagram-v2.spec.js index 53063f41a..d1edc5b40 100644 --- a/packages/mermaid/src/diagrams/state/stateDiagram-v2.spec.js +++ b/packages/mermaid/src/diagrams/state/stateDiagram-v2.spec.js @@ -1,11 +1,13 @@ import { parser } from './parser/stateDiagram.jison'; -import stateDb from './stateDb.js'; +import { StateDB } from './stateDb.js'; import stateDiagram from './parser/stateDiagram.jison'; describe('state diagram V2, ', function () { // TODO - these examples should be put into ./parser/stateDiagram.spec.js describe('when parsing an info graph it', function () { + let stateDb; beforeEach(function () { + stateDb = new StateDB(); parser.yy = stateDb; stateDiagram.parser.yy = stateDb; stateDiagram.parser.yy.clear(); diff --git a/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts b/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts index a27fc1879..f7bc716c6 100644 --- a/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts +++ b/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts @@ -1,13 +1,15 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; // @ts-ignore: JISON doesn't support types import parser from './parser/stateDiagram.jison'; -import db from './stateDb.js'; +import { StateDB } from './stateDb.js'; import styles from './styles.js'; import renderer from './stateRenderer-v3-unified.js'; export const diagram: DiagramDefinition = { parser, - db, + get db() { + return new StateDB(); + }, renderer, styles, init: (cnf) => { @@ -15,6 +17,5 @@ export const diagram: DiagramDefinition = { cnf.state = {}; } cnf.state.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute; - db.clear(); }, }; diff --git a/packages/mermaid/src/diagrams/state/stateDiagram.spec.js b/packages/mermaid/src/diagrams/state/stateDiagram.spec.js index 7fcf4d0a6..8175ef041 100644 --- a/packages/mermaid/src/diagrams/state/stateDiagram.spec.js +++ b/packages/mermaid/src/diagrams/state/stateDiagram.spec.js @@ -1,9 +1,11 @@ import { parser } from './parser/stateDiagram.jison'; -import stateDb from './stateDb.js'; +import { StateDB } from './stateDb.js'; describe('state diagram, ', function () { describe('when parsing an info graph it', function () { + let stateDb; beforeEach(function () { + stateDb = new StateDB(); parser.yy = stateDb; }); diff --git a/packages/mermaid/src/diagrams/state/stateDiagram.ts b/packages/mermaid/src/diagrams/state/stateDiagram.ts index 643e847ce..a6f9d7c63 100644 --- a/packages/mermaid/src/diagrams/state/stateDiagram.ts +++ b/packages/mermaid/src/diagrams/state/stateDiagram.ts @@ -1,13 +1,15 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; // @ts-ignore: JISON doesn't support types import parser from './parser/stateDiagram.jison'; -import db from './stateDb.js'; +import { StateDB } from './stateDb.js'; import styles from './styles.js'; import renderer from './stateRenderer.js'; export const diagram: DiagramDefinition = { parser, - db, + get db() { + return new StateDB(); + }, renderer, styles, init: (cnf) => { @@ -15,6 +17,5 @@ export const diagram: DiagramDefinition = { cnf.state = {}; } cnf.state.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute; - db.clear(); }, }; diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index 06a632082..0b1a0e4de 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; // ------------------------------------- // Mocks and mocking @@ -69,6 +69,8 @@ import { compile, serialize } from 'stylis'; import { Diagram } from './Diagram.js'; import { decodeEntities, encodeEntities } from './utils.js'; import { toBase64 } from './utils/base64.js'; +import { ClassDB } from './diagrams/class/classDb.js'; +import { StateDB } from './diagrams/state/stateDb.js'; import { FlowDB } from './diagrams/flowchart/flowDb.js'; /** @@ -853,40 +855,102 @@ graph TD;A--x|text including URL space|B;` }); it('should not modify db when rendering different diagrams', async () => { + const stateDiagram1 = await mermaidAPI.getDiagramFromText( + `stateDiagram + direction LR + [*] --> Still + Still --> [*] + Still --> Moving + Moving --> Still + Moving --> Crash + Crash --> [*]` + ); + const stateDiagram2 = await mermaidAPI.getDiagramFromText( + `stateDiagram + direction TB + [*] --> Still + Still --> [*] + Still --> Moving + Moving --> Still + Moving --> Crash + Crash --> [*]` + ); + // Since stateDiagram will return new Db object each time, we can compare the db to be different. + expect(stateDiagram1.db).not.toBe(stateDiagram2.db); + assert(stateDiagram1.db instanceof StateDB); + assert(stateDiagram2.db instanceof StateDB); + expect(stateDiagram1.db.getDirection()).not.toEqual(stateDiagram2.db.getDirection()); + const flowDiagram1 = await mermaidAPI.getDiagramFromText( `flowchart LR A -- text --> B -- text2 --> C` ); - const flwoDiagram2 = await mermaidAPI.getDiagramFromText( + const flowDiagram2 = await mermaidAPI.getDiagramFromText( `flowchart TD A -- text --> B -- text2 --> C` ); // Since flowDiagram will return new Db object each time, we can compare the db to be different. - expect(flowDiagram1.db).not.toBe(flwoDiagram2.db); + expect(flowDiagram1.db).not.toBe(flowDiagram2.db); assert(flowDiagram1.db instanceof FlowDB); - assert(flwoDiagram2.db instanceof FlowDB); - expect(flowDiagram1.db.getDirection()).not.toEqual(flwoDiagram2.db.getDirection()); + assert(flowDiagram2.db instanceof FlowDB); + expect(flowDiagram1.db.getDirection()).not.toEqual(flowDiagram2.db.getDirection()); const classDiagram1 = await mermaidAPI.getDiagramFromText( - `stateDiagram - [*] --> Still - Still --> [*] - Still --> Moving - Moving --> Still - Moving --> Crash - Crash --> [*]` + `classDiagram + direction TB + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides` ); const classDiagram2 = await mermaidAPI.getDiagramFromText( - `stateDiagram - [*] --> Still - Still --> [*] - Still --> Moving - Moving --> Still - Moving --> Crash - Crash --> [*]` + `classDiagram + direction LR + class Student { + -idCard : IdCard + } + class IdCard{ + -id : int + -name : string + } + class Bike{ + -id : int + -name : string + } + Student "1" --o "1" IdCard : carries + Student "1" --o "1" Bike : rides` + ); + // Since classDiagram will return new Db object each time, we can compare the db to be different. + expect(classDiagram1.db).not.toBe(classDiagram2.db); + assert(classDiagram1.db instanceof ClassDB); + assert(classDiagram2.db instanceof ClassDB); + expect(classDiagram1.db.getDirection()).not.toEqual(classDiagram2.db.getDirection()); + + const sequenceDiagram1 = await mermaidAPI.getDiagramFromText( + `sequenceDiagram + Alice->>+John: Hello John, how are you? + Alice->>+John: John, can you hear me? + John-->>-Alice: Hi Alice, I can hear you! + John-->>-Alice: I feel great!` + ); + const sequenceDiagram2 = await mermaidAPI.getDiagramFromText( + `sequenceDiagram + Alice->>+John: Hello John, how are you? + Alice->>+John: John, can you hear me? + John-->>-Alice: Hi Alice, I can hear you! + John-->>-Alice: I feel great!` ); // Since sequenceDiagram will return same Db object each time, we can compare the db to be same. - expect(classDiagram1.db).toBe(classDiagram2.db); + expect(sequenceDiagram1.db).toBe(sequenceDiagram2.db); }); });