From 8fba9c12363c2f1ffe8df9335428f24920b049e4 Mon Sep 17 00:00:00 2001 From: saurabhg772244 Date: Wed, 15 Jan 2025 18:02:04 +0530 Subject: [PATCH] convert flowDb to class and added test cases. --- .../mermaid/src/diagrams/class/classDb.ts | 1283 ++++++++--------- .../class/classDiagram-styles.spec.js | 4 +- .../src/diagrams/class/classDiagram-v2.ts | 7 +- .../src/diagrams/class/classDiagram.spec.ts | 68 +- .../src/diagrams/class/classDiagram.ts | 7 +- .../src/diagrams/class/parser/class.spec.js | 4 +- packages/mermaid/src/mermaidAPI.spec.ts | 44 + 7 files changed, 748 insertions(+), 669 deletions(-) diff --git a/packages/mermaid/src/diagrams/class/classDb.ts b/packages/mermaid/src/diagrams/class/classDb.ts index 569943736..67e0b30a8 100644 --- a/packages/mermaid/src/diagrams/class/classDb.ts +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -24,713 +24,694 @@ 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[] = []; +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; -const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig()); + private functions: any[] = []; -const splitClassNameAndType = function (_id: string) { - const id = common.sanitizeText(_id, getConfig()); - let genericType = ''; - let className = id; + private readonly sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig()); - if (id.indexOf('~') > 0) { - const split = id.split('~'); - className = sanitizeText(split[0]); - genericType = sanitizeText(split[1]); + constructor() { + this.functions.push(this.setupToolTips); + this.clear(); } - return { className: className, type: genericType }; -}; + private readonly 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); - } + if (id.indexOf('~') > 0) { + const split = id.split('~'); + className = this.sanitizeText(split[0]); + genericType = this.sanitizeText(split[1]); + } - 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, + return { className: className, type: genericType }; }; - 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')); + public setClassLabel = (_id: string, label: string) => { + const id = common.sanitizeText(_id, getConfig()); + if (label) { + label = this.sanitizeText(label); } - } -}; -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, + 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}>` : ''}`; }; - 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; + /** + * 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; } - const classNode = classes.get(id); - if (classNode) { - classNode.cssClasses += ' ' + className; - } - }); -}; + // 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); -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); + classCounter++; + }; + + private readonly 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); + 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); } - if (style) { - style.forEach(function (s) { - if (/color/.exec(s)) { - const newStyle = s.replace('fill', 'bgFill'); // .replace('color', 'fill'); - styleClass.textStyles.push(newStyle); + 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( + this.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 this.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 = this.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 = this.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 - ); } }); - } -}; + this.setCssClass(ids, 'clickable'); + }; -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); - } - - 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); + this.setCssClass(ids, 'clickable'); + }; -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; - } - - namespaces.set(id, { - id: id, - classes: new Map(), - children: {}, - domId: MERMAID_DOM_ID_PREFIX + id + '-' + namespaceCounter, - } as NamespaceNode); - - namespaceCounter++; -}; - -const getNamespace = function (name: string): NamespaceNode { - return namespaces.get(name)!; -}; - -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); + private readonly 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; } - } -}; -/** - * 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'; - } - return marker; -} + 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; + } + } -export const getData = () => { - const nodes: Node[] = []; - const edges: Edge[] = []; - const config = getConfig(); + /* if no arguments passed into callback, default to passing in id */ + if (argList.length === 0) { + argList.push(elemId); + } - 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'], + this.functions.push(() => { + const elem = document.querySelector(`[id="${elemId}"]`); + if (elem !== null) { + elem.addEventListener( + 'click', + () => { + utils.runFunc(functionName, ...argList); + }, + false + ); + } + }); + } + }; + + public bindFunctions = (element: Element) => { + this.functions.forEach((fun) => { + fun(element); + }); + }; + + public lineType = { + LINE: 0, + DOTTED_LINE: 1, + }; + + public 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 = () => 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 readonly 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); } - } - 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); - } + return { nodes, edges, other: {}, config, direction: this.getDirection() }; + }; - 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..ed508a5d8 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts @@ -1,6 +1,6 @@ // @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 +10,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 +565,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 +658,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 +749,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 +861,9 @@ foo() }); describe('when parsing annotations', function () { + let classDb: ClassDB; beforeEach(function () { - classDb.clear(); + classDb = new ClassDB(); parser.yy = classDb; }); @@ -921,8 +926,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 +986,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 +1074,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 +1188,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 +1723,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 +1908,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/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index 5bd1b1dfc..161b247b5 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import assert from 'node:assert'; // ------------------------------------- // Mocks and mocking @@ -69,6 +70,7 @@ 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'; /** * @see https://vitest.dev/guide/mocking.html Mock part of a module @@ -833,4 +835,46 @@ graph TD;A--x|text including URL space|B;`) expect(diagram.type).toBe('flowchart-v2'); }); }); + + it('should not modify db when rendering different diagrams', async () => { + const classDiagram1 = await mermaidAPI.getDiagramFromText( + `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( + `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()); + }); });