diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts index 69ddeaf18..e71177b1f 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDb.ts +++ b/packages/mermaid/src/diagrams/sequence/sequenceDb.ts @@ -1,4 +1,5 @@ import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { DiagramDB } from '../../diagram-api/types.js'; import { log } from '../../logger.js'; import { ImperativeState } from '../../utils/imperativeState.js'; import { sanitizeText } from '../common/common.js'; @@ -28,641 +29,612 @@ interface SequenceState { lastDestroyed?: Actor; } -const state = new ImperativeState(() => ({ - prevActor: undefined, - actors: new Map(), - createdActors: new Map(), - destroyedActors: new Map(), - boxes: [], - messages: [], - notes: [], - sequenceNumbersEnabled: false, - wrapEnabled: undefined, - currentBox: undefined, - lastCreated: undefined, - lastDestroyed: undefined, -})); +export class SequenceDB implements DiagramDB { + private readonly state = new ImperativeState(() => ({ + prevActor: undefined, + actors: new Map(), + createdActors: new Map(), + destroyedActors: new Map(), + boxes: [], + messages: [], + notes: [], + sequenceNumbersEnabled: false, + wrapEnabled: undefined, + currentBox: undefined, + lastCreated: undefined, + lastDestroyed: undefined, + })); -export const addBox = function (data: { text: string; color: string; wrap: boolean }) { - state.records.boxes.push({ - name: data.text, - wrap: data.wrap ?? autoWrap(), - fill: data.color, - actorKeys: [], - }); - state.records.currentBox = state.records.boxes.slice(-1)[0]; -}; - -export const addActor = function ( - id: string, - name: string, - description: { text: string; wrap?: boolean | null; type: string }, - type: string -) { - let assignedBox = state.records.currentBox; - const old = state.records.actors.get(id); - if (old) { - // If already set and trying to set to a new one throw error - if (state.records.currentBox && old.box && state.records.currentBox !== old.box) { - throw new Error( - `A same participant should only be defined in one Box: ${old.name} can't be in '${old.box.name}' and in '${state.records.currentBox.name}' at the same time.` - ); - } - - // Don't change the box if already - assignedBox = old.box ? old.box : state.records.currentBox; - old.box = assignedBox; - - // Don't allow description nulling - if (old && name === old.name && description == null) { - return; - } - } - - // Don't allow null descriptions, either - if (description?.text == null) { - description = { text: name, type }; - } - if (type == null || description.text == null) { - description = { text: name, type }; - } - - state.records.actors.set(id, { - box: assignedBox, - name: name, - description: description.text, - wrap: description.wrap ?? autoWrap(), - prevActor: state.records.prevActor, - links: {}, - properties: {}, - actorCnt: null, - rectData: null, - type: type ?? 'participant', - }); - if (state.records.prevActor) { - const prevActorInRecords = state.records.actors.get(state.records.prevActor); - if (prevActorInRecords) { - prevActorInRecords.nextActor = id; - } - } - - if (state.records.currentBox) { - state.records.currentBox.actorKeys.push(id); - } - state.records.prevActor = id; -}; - -const activationCount = (part: string) => { - let i; - let count = 0; - if (!part) { - return 0; - } - for (i = 0; i < state.records.messages.length; i++) { - if ( - state.records.messages[i].type === LINETYPE.ACTIVE_START && - state.records.messages[i].from === part - ) { - count++; - } - if ( - state.records.messages[i].type === LINETYPE.ACTIVE_END && - state.records.messages[i].from === part - ) { - count--; - } - } - return count; -}; - -export const addMessage = function ( - idFrom: Message['from'], - idTo: Message['to'], - message: { text: string; wrap?: boolean }, - answer: Message['answer'] -) { - state.records.messages.push({ - from: idFrom, - to: idTo, - message: message.text, - wrap: message.wrap ?? autoWrap(), - answer: answer, - }); -}; - -export const addSignal = function ( - idFrom?: Message['from'], - idTo?: Message['to'], - message?: { text: string; wrap: boolean }, - messageType?: number, - activate = false -) { - if (messageType === LINETYPE.ACTIVE_END) { - const cnt = activationCount(idFrom ?? ''); - if (cnt < 1) { - // Bail out as there is an activation signal from an inactive participant - const error = new Error('Trying to inactivate an inactive participant (' + idFrom + ')'); - - // @ts-ignore: we are passing hash param to the error object, however we should define our own custom error class to make it type safe - error.hash = { - text: '->>-', - token: '->>-', - line: '1', - loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 }, - expected: ["'ACTIVE_PARTICIPANT'"], - }; - throw error; - } - } - state.records.messages.push({ - from: idFrom, - to: idTo, - message: message?.text ?? '', - wrap: message?.wrap ?? autoWrap(), - type: messageType, - activate, - }); - return true; -}; - -export const hasAtLeastOneBox = function () { - return state.records.boxes.length > 0; -}; - -export const hasAtLeastOneBoxWithTitle = function () { - return state.records.boxes.some((b) => b.name); -}; - -export const getMessages = function () { - return state.records.messages; -}; - -export const getBoxes = function () { - return state.records.boxes; -}; -export const getActors = function () { - return state.records.actors; -}; -export const getCreatedActors = function () { - return state.records.createdActors; -}; -export const getDestroyedActors = function () { - return state.records.destroyedActors; -}; -export const getActor = function (id: string) { - // TODO: do we ever use this function in a way that it might return undefined? - return state.records.actors.get(id)!; -}; -export const getActorKeys = function () { - return [...state.records.actors.keys()]; -}; -export const enableSequenceNumbers = function () { - state.records.sequenceNumbersEnabled = true; -}; -export const disableSequenceNumbers = function () { - state.records.sequenceNumbersEnabled = false; -}; -export const showSequenceNumbers = () => state.records.sequenceNumbersEnabled; - -export const setWrap = function (wrapSetting?: boolean) { - state.records.wrapEnabled = wrapSetting; -}; - -const extractWrap = (text?: string): { cleanedText?: string; wrap?: boolean } => { - if (text === undefined) { - return {}; - } - text = text.trim(); - const wrap = - /^:?wrap:/.exec(text) !== null ? true : /^:?nowrap:/.exec(text) !== null ? false : undefined; - const cleanedText = (wrap === undefined ? text : text.replace(/^:?(?:no)?wrap:/, '')).trim(); - return { cleanedText, wrap }; -}; - -export const autoWrap = () => { - // if setWrap has been called, use that value, otherwise use the value from the config - // TODO: refactor, always use the config value let setWrap update the config value - if (state.records.wrapEnabled !== undefined) { - return state.records.wrapEnabled; - } - return getConfig().sequence?.wrap ?? false; -}; - -export const clear = function () { - state.reset(); - commonClear(); -}; - -export const parseMessage = function (str: string) { - const trimmedStr = str.trim(); - const { wrap, cleanedText } = extractWrap(trimmedStr); - const message = { - text: cleanedText, - wrap, - }; - log.debug(`parseMessage: ${JSON.stringify(message)}`); - return message; -}; - -// We expect the box statement to be color first then description -// The color can be rgb,rgba,hsl,hsla, or css code names #hex codes are not supported for now because of the way the char # is handled -// We extract first segment as color, the rest of the line is considered as text -export const parseBoxData = function (str: string) { - const match = /^((?:rgba?|hsla?)\s*\(.*\)|\w*)(.*)$/.exec(str); - let color = match?.[1] ? match[1].trim() : 'transparent'; - let title = match?.[2] ? match[2].trim() : undefined; - - // check that the string is a color - if (window?.CSS) { - if (!window.CSS.supports('color', color)) { - color = 'transparent'; - title = str.trim(); - } - } else { - const style = new Option().style; - style.color = color; - if (style.color !== color) { - color = 'transparent'; - title = str.trim(); - } - } - const { wrap, cleanedText } = extractWrap(title); - return { - text: cleanedText ? sanitizeText(cleanedText, getConfig()) : undefined, - color, - wrap, - }; -}; - -export const LINETYPE = { - SOLID: 0, - DOTTED: 1, - NOTE: 2, - SOLID_CROSS: 3, - DOTTED_CROSS: 4, - SOLID_OPEN: 5, - DOTTED_OPEN: 6, - LOOP_START: 10, - LOOP_END: 11, - ALT_START: 12, - ALT_ELSE: 13, - ALT_END: 14, - OPT_START: 15, - OPT_END: 16, - ACTIVE_START: 17, - ACTIVE_END: 18, - PAR_START: 19, - PAR_AND: 20, - PAR_END: 21, - RECT_START: 22, - RECT_END: 23, - SOLID_POINT: 24, - DOTTED_POINT: 25, - AUTONUMBER: 26, - CRITICAL_START: 27, - CRITICAL_OPTION: 28, - CRITICAL_END: 29, - BREAK_START: 30, - BREAK_END: 31, - PAR_OVER_START: 32, - BIDIRECTIONAL_SOLID: 33, - BIDIRECTIONAL_DOTTED: 34, -}; - -export const ARROWTYPE = { - FILLED: 0, - OPEN: 1, -}; - -export const PLACEMENT = { - LEFTOF: 0, - RIGHTOF: 1, - OVER: 2, -}; - -export const addNote = function ( - actor: { actor: string }, - placement: Message['placement'], - message: { text: string; wrap?: boolean } -) { - const note: Note = { - actor: actor, - placement: placement, - message: message.text, - wrap: message.wrap ?? autoWrap(), - }; - - //@ts-ignore: Coerce actor into a [to, from, ...] array - // eslint-disable-next-line unicorn/prefer-spread - const actors = [].concat(actor, actor); - state.records.notes.push(note); - state.records.messages.push({ - from: actors[0], - to: actors[1], - message: message.text, - wrap: message.wrap ?? autoWrap(), - type: LINETYPE.NOTE, - placement: placement, - }); -}; - -export const addLinks = function (actorId: string, text: { text: string }) { - // find the actor - const actor = getActor(actorId); - // JSON.parse the text - try { - let sanitizedText = sanitizeText(text.text, getConfig()); - sanitizedText = sanitizedText.replace(/&/g, '&'); - sanitizedText = sanitizedText.replace(/=/g, '='); - const links = JSON.parse(sanitizedText); - // add the deserialized text to the actor's links field. - insertLinks(actor, links); - } catch (e) { - log.error('error while parsing actor link text', e); - } -}; - -export const addALink = function (actorId: string, text: { text: string }) { - // find the actor - const actor = getActor(actorId); - try { - const links: Record = {}; - let sanitizedText = sanitizeText(text.text, getConfig()); - const sep = sanitizedText.indexOf('@'); - sanitizedText = sanitizedText.replace(/&/g, '&'); - sanitizedText = sanitizedText.replace(/=/g, '='); - const label = sanitizedText.slice(0, sep - 1).trim(); - const link = sanitizedText.slice(sep + 1).trim(); - - links[label] = link; - // add the deserialized text to the actor's links field. - insertLinks(actor, links); - } catch (e) { - log.error('error while parsing actor link text', e); - } -}; - -/** - * @param actor - the actor to add the links to - * @param links - the links to add to the actor - */ -function insertLinks(actor: Actor, links: Record) { - if (actor.links == null) { - actor.links = links; - } else { - for (const key in links) { - actor.links[key] = links[key]; - } - } -} - -export const addProperties = function (actorId: string, text: { text: string }) { - // find the actor - const actor = getActor(actorId); - // JSON.parse the text - try { - const sanitizedText = sanitizeText(text.text, getConfig()); - const properties: Record = JSON.parse(sanitizedText); - // add the deserialized text to the actor's property field. - insertProperties(actor, properties); - } catch (e) { - log.error('error while parsing actor properties text', e); - } -}; - -/** - * @param actor - the actor to add the properties to - * @param properties - the properties to add to the actor's properties - */ -function insertProperties(actor: Actor, properties: Record) { - if (actor.properties == null) { - actor.properties = properties; - } else { - for (const key in properties) { - actor.properties[key] = properties[key]; - } - } -} - -function boxEnd() { - state.records.currentBox = undefined; -} - -export const addDetails = function (actorId: string, text: { text: string }) { - // find the actor - const actor = getActor(actorId); - const elem = document.getElementById(text.text)!; - - // JSON.parse the text - try { - const text = elem.innerHTML; - const details = JSON.parse(text); - // add the deserialized text to the actor's property field. - if (details.properties) { - insertProperties(actor, details.properties); - } - - if (details.links) { - insertLinks(actor, details.links); - } - } catch (e) { - log.error('error while parsing actor details text', e); - } -}; - -export const getActorProperty = function (actor: Actor, key: string) { - if (actor?.properties !== undefined) { - return actor.properties[key]; - } - - return undefined; -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-redundant-type-constituents -export const apply = function (param: any | AddMessageParams | AddMessageParams[]) { - if (Array.isArray(param)) { - param.forEach(function (item) { - apply(item); + public addBox = (data: { text: string; color: string; wrap: boolean }) => { + this.state.records.boxes.push({ + name: data.text, + wrap: data.wrap ?? this.autoWrap(), + fill: data.color, + actorKeys: [], }); - } else { - switch (param.type) { - case 'sequenceIndex': - state.records.messages.push({ - from: undefined, - to: undefined, - message: { - start: param.sequenceIndex, - step: param.sequenceIndexStep, - visible: param.sequenceVisible, - }, - wrap: false, - type: param.signalType, - }); - break; - case 'addParticipant': - addActor(param.actor, param.actor, param.description, param.draw); - break; - case 'createParticipant': - if (state.records.actors.has(param.actor)) { - throw new Error( - "It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior" - ); - } - state.records.lastCreated = param.actor; - addActor(param.actor, param.actor, param.description, param.draw); - state.records.createdActors.set(param.actor, state.records.messages.length); - break; - case 'destroyParticipant': - state.records.lastDestroyed = param.actor; - state.records.destroyedActors.set(param.actor, state.records.messages.length); - break; - case 'activeStart': - addSignal(param.actor, undefined, undefined, param.signalType); - break; - case 'activeEnd': - addSignal(param.actor, undefined, undefined, param.signalType); - break; - case 'addNote': - addNote(param.actor, param.placement, param.text); - break; - case 'addLinks': - addLinks(param.actor, param.text); - break; - case 'addALink': - addALink(param.actor, param.text); - break; - case 'addProperties': - addProperties(param.actor, param.text); - break; - case 'addDetails': - addDetails(param.actor, param.text); - break; - case 'addMessage': - if (state.records.lastCreated) { - if (param.to !== state.records.lastCreated) { - throw new Error( - 'The created participant ' + - state.records.lastCreated.name + - ' does not have an associated creating message after its declaration. Please check the sequence diagram.' - ); - } else { - state.records.lastCreated = undefined; - } - } else if (state.records.lastDestroyed) { - if ( - param.to !== state.records.lastDestroyed && - param.from !== state.records.lastDestroyed - ) { - throw new Error( - 'The destroyed participant ' + - state.records.lastDestroyed.name + - ' does not have an associated destroying message after its declaration. Please check the sequence diagram.' - ); - } else { - state.records.lastDestroyed = undefined; - } - } - addSignal(param.from, param.to, param.msg, param.signalType, param.activate); - break; - case 'boxStart': - addBox(param.boxData); - break; - case 'boxEnd': - boxEnd(); - break; - case 'loopStart': - addSignal(undefined, undefined, param.loopText, param.signalType); - break; - case 'loopEnd': - addSignal(undefined, undefined, undefined, param.signalType); - break; - case 'rectStart': - addSignal(undefined, undefined, param.color, param.signalType); - break; - case 'rectEnd': - addSignal(undefined, undefined, undefined, param.signalType); - break; - case 'optStart': - addSignal(undefined, undefined, param.optText, param.signalType); - break; - case 'optEnd': - addSignal(undefined, undefined, undefined, param.signalType); - break; - case 'altStart': - addSignal(undefined, undefined, param.altText, param.signalType); - break; - case 'else': - addSignal(undefined, undefined, param.altText, param.signalType); - break; - case 'altEnd': - addSignal(undefined, undefined, undefined, param.signalType); - break; - case 'setAccTitle': - setAccTitle(param.text); - break; - case 'parStart': - addSignal(undefined, undefined, param.parText, param.signalType); - break; - case 'and': - addSignal(undefined, undefined, param.parText, param.signalType); - break; - case 'parEnd': - addSignal(undefined, undefined, undefined, param.signalType); - break; - case 'criticalStart': - addSignal(undefined, undefined, param.criticalText, param.signalType); - break; - case 'option': - addSignal(undefined, undefined, param.optionText, param.signalType); - break; - case 'criticalEnd': - addSignal(undefined, undefined, undefined, param.signalType); - break; - case 'breakStart': - addSignal(undefined, undefined, param.breakText, param.signalType); - break; - case 'breakEnd': - addSignal(undefined, undefined, undefined, param.signalType); - break; - } - } -}; + this.state.records.currentBox = this.state.records.boxes.slice(-1)[0]; + }; -export default { - addActor, - addMessage, - addSignal, - addLinks, - addDetails, - addProperties, - autoWrap, - setWrap, - enableSequenceNumbers, - disableSequenceNumbers, - showSequenceNumbers, - getMessages, - getActors, - getCreatedActors, - getDestroyedActors, - getActor, - getActorKeys, - getActorProperty, - getAccTitle, - getBoxes, - getDiagramTitle, - setDiagramTitle, - getConfig: () => getConfig().sequence, - clear, - parseMessage, - parseBoxData, - LINETYPE, - ARROWTYPE, - PLACEMENT, - addNote, - setAccTitle, - apply, - setAccDescription, - getAccDescription, - hasAtLeastOneBox, - hasAtLeastOneBoxWithTitle, -}; + public addActor = ( + id: string, + name: string, + description: { text: string; wrap?: boolean | null; type: string }, + type: string + ) => { + let assignedBox = this.state.records.currentBox; + const old = this.state.records.actors.get(id); + if (old) { + // If already set and trying to set to a new one throw error + if (this.state.records.currentBox && old.box && this.state.records.currentBox !== old.box) { + throw new Error( + `A same participant should only be defined in one Box: ${old.name} can't be in '${old.box.name}' and in '${this.state.records.currentBox.name}' at the same time.` + ); + } + + // Don't change the box if already + assignedBox = old.box ? old.box : this.state.records.currentBox; + old.box = assignedBox; + + // Don't allow description nulling + if (old && name === old.name && description == null) { + return; + } + } + + // Don't allow null descriptions, either + if (description?.text == null) { + description = { text: name, type }; + } + if (type == null || description.text == null) { + description = { text: name, type }; + } + + this.state.records.actors.set(id, { + box: assignedBox, + name: name, + description: description.text, + wrap: description.wrap ?? this.autoWrap(), + prevActor: this.state.records.prevActor, + links: {}, + properties: {}, + actorCnt: null, + rectData: null, + type: type ?? 'participant', + }); + if (this.state.records.prevActor) { + const prevActorInRecords = this.state.records.actors.get(this.state.records.prevActor); + if (prevActorInRecords) { + prevActorInRecords.nextActor = id; + } + } + + if (this.state.records.currentBox) { + this.state.records.currentBox.actorKeys.push(id); + } + this.state.records.prevActor = id; + }; + + private readonly activationCount = (part: string) => { + let i; + let count = 0; + if (!part) { + return 0; + } + for (i = 0; i < this.state.records.messages.length; i++) { + if ( + this.state.records.messages[i].type === this.LINETYPE.ACTIVE_START && + this.state.records.messages[i].from === part + ) { + count++; + } + if ( + this.state.records.messages[i].type === this.LINETYPE.ACTIVE_END && + this.state.records.messages[i].from === part + ) { + count--; + } + } + return count; + }; + + public addMessage = ( + idFrom: Message['from'], + idTo: Message['to'], + message: { text: string; wrap?: boolean }, + answer: Message['answer'] + ) => { + this.state.records.messages.push({ + from: idFrom, + to: idTo, + message: message.text, + wrap: message.wrap ?? this.autoWrap(), + answer: answer, + }); + }; + + public addSignal = ( + idFrom?: Message['from'], + idTo?: Message['to'], + message?: { text: string; wrap: boolean }, + messageType?: number, + activate = false + ) => { + if (messageType === this.LINETYPE.ACTIVE_END) { + const cnt = this.activationCount(idFrom ?? ''); + if (cnt < 1) { + // Bail out as there is an activation signal from an inactive participant + const error = new Error('Trying to inactivate an inactive participant (' + idFrom + ')'); + + // @ts-ignore: we are passing hash param to the error object, however we should define our own custom error class to make it type safe + error.hash = { + text: '->>-', + token: '->>-', + line: '1', + loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 }, + expected: ["'ACTIVE_PARTICIPANT'"], + }; + throw error; + } + } + this.state.records.messages.push({ + from: idFrom, + to: idTo, + message: message?.text ?? '', + wrap: message?.wrap ?? this.autoWrap(), + type: messageType, + activate, + }); + return true; + }; + + public hasAtLeastOneBox = () => { + return this.state.records.boxes.length > 0; + }; + + public hasAtLeastOneBoxWithTitle = () => { + return this.state.records.boxes.some((b) => b.name); + }; + + public getMessages = () => { + return this.state.records.messages; + }; + + public getBoxes = () => { + return this.state.records.boxes; + }; + public getActors = () => { + return this.state.records.actors; + }; + public getCreatedActors = () => { + return this.state.records.createdActors; + }; + public getDestroyedActors = () => { + return this.state.records.destroyedActors; + }; + public getActor = (id: string) => { + // TODO: do we ever use this function in a way that it might return undefined? + return this.state.records.actors.get(id)!; + }; + public getActorKeys = () => { + return [...this.state.records.actors.keys()]; + }; + public enableSequenceNumbers = () => { + this.state.records.sequenceNumbersEnabled = true; + }; + public disableSequenceNumbers = () => { + this.state.records.sequenceNumbersEnabled = false; + }; + public showSequenceNumbers = () => this.state.records.sequenceNumbersEnabled; + + public setWrap = (wrapSetting?: boolean) => { + this.state.records.wrapEnabled = wrapSetting; + }; + + private readonly extractWrap = (text?: string): { cleanedText?: string; wrap?: boolean } => { + if (text === undefined) { + return {}; + } + text = text.trim(); + const wrap = + /^:?wrap:/.exec(text) !== null ? true : /^:?nowrap:/.exec(text) !== null ? false : undefined; + const cleanedText = (wrap === undefined ? text : text.replace(/^:?(?:no)?wrap:/, '')).trim(); + return { cleanedText, wrap }; + }; + + public autoWrap = () => { + // if setWrap has been called, use that value, otherwise use the value from the config + // TODO: refactor, always use the config value let setWrap update the config value + if (this.state.records.wrapEnabled !== undefined) { + return this.state.records.wrapEnabled; + } + return getConfig().sequence?.wrap ?? false; + }; + + public clear = () => { + this.state.reset(); + commonClear(); + }; + + public parseMessage = (str: string) => { + const trimmedStr = str.trim(); + const { wrap, cleanedText } = this.extractWrap(trimmedStr); + const message = { + text: cleanedText, + wrap, + }; + log.debug(`parseMessage: ${JSON.stringify(message)}`); + return message; + }; + + // We expect the box statement to be color first then description + // The color can be rgb,rgba,hsl,hsla, or css code names #hex codes are not supported for now because of the way the char # is handled + // We extract first segment as color, the rest of the line is considered as text + public parseBoxData = (str: string) => { + const match = /^((?:rgba?|hsla?)\s*\(.*\)|\w*)(.*)$/.exec(str); + let color = match?.[1] ? match[1].trim() : 'transparent'; + let title = match?.[2] ? match[2].trim() : undefined; + + // check that the string is a color + if (window?.CSS) { + if (!window.CSS.supports('color', color)) { + color = 'transparent'; + title = str.trim(); + } + } else { + const style = new Option().style; + style.color = color; + if (style.color !== color) { + color = 'transparent'; + title = str.trim(); + } + } + const { wrap, cleanedText } = this.extractWrap(title); + return { + text: cleanedText ? sanitizeText(cleanedText, getConfig()) : undefined, + color, + wrap, + }; + }; + + public LINETYPE = { + SOLID: 0, + DOTTED: 1, + NOTE: 2, + SOLID_CROSS: 3, + DOTTED_CROSS: 4, + SOLID_OPEN: 5, + DOTTED_OPEN: 6, + LOOP_START: 10, + LOOP_END: 11, + ALT_START: 12, + ALT_ELSE: 13, + ALT_END: 14, + OPT_START: 15, + OPT_END: 16, + ACTIVE_START: 17, + ACTIVE_END: 18, + PAR_START: 19, + PAR_AND: 20, + PAR_END: 21, + RECT_START: 22, + RECT_END: 23, + SOLID_POINT: 24, + DOTTED_POINT: 25, + AUTONUMBER: 26, + CRITICAL_START: 27, + CRITICAL_OPTION: 28, + CRITICAL_END: 29, + BREAK_START: 30, + BREAK_END: 31, + PAR_OVER_START: 32, + BIDIRECTIONAL_SOLID: 33, + BIDIRECTIONAL_DOTTED: 34, + }; + + public ARROWTYPE = { + FILLED: 0, + OPEN: 1, + }; + + public PLACEMENT = { + LEFTOF: 0, + RIGHTOF: 1, + OVER: 2, + }; + + public addNote = ( + actor: { actor: string }, + placement: Message['placement'], + message: { text: string; wrap?: boolean } + ) => { + const note: Note = { + actor: actor, + placement: placement, + message: message.text, + wrap: message.wrap ?? this.autoWrap(), + }; + + //@ts-ignore: Coerce actor into a [to, from, ...] array + // eslint-disable-next-line unicorn/prefer-spread + const actors = [].concat(actor, actor); + this.state.records.notes.push(note); + this.state.records.messages.push({ + from: actors[0], + to: actors[1], + message: message.text, + wrap: message.wrap ?? this.autoWrap(), + type: this.LINETYPE.NOTE, + placement: placement, + }); + }; + + public addLinks = (actorId: string, text: { text: string }) => { + // find the actor + const actor = this.getActor(actorId); + // JSON.parse the text + try { + let sanitizedText = sanitizeText(text.text, getConfig()); + sanitizedText = sanitizedText.replace(/&/g, '&'); + sanitizedText = sanitizedText.replace(/=/g, '='); + const links = JSON.parse(sanitizedText); + // add the deserialized text to the actor's links field. + this.insertLinks(actor, links); + } catch (e) { + log.error('error while parsing actor link text', e); + } + }; + + public addALink = (actorId: string, text: { text: string }) => { + // find the actor + const actor = this.getActor(actorId); + try { + const links: Record = {}; + let sanitizedText = sanitizeText(text.text, getConfig()); + const sep = sanitizedText.indexOf('@'); + sanitizedText = sanitizedText.replace(/&/g, '&'); + sanitizedText = sanitizedText.replace(/=/g, '='); + const label = sanitizedText.slice(0, sep - 1).trim(); + const link = sanitizedText.slice(sep + 1).trim(); + + links[label] = link; + // add the deserialized text to the actor's links field. + this.insertLinks(actor, links); + } catch (e) { + log.error('error while parsing actor link text', e); + } + }; + + /** + * @param actor - the actor to add the links to + * @param links - the links to add to the actor + */ + private readonly insertLinks = (actor: Actor, links: Record) => { + if (actor.links == null) { + actor.links = links; + } else { + for (const key in links) { + actor.links[key] = links[key]; + } + } + }; + + public addProperties = (actorId: string, text: { text: string }) => { + // find the actor + const actor = this.getActor(actorId); + // JSON.parse the text + try { + const sanitizedText = sanitizeText(text.text, getConfig()); + const properties: Record = JSON.parse(sanitizedText); + // add the deserialized text to the actor's property field. + this.insertProperties(actor, properties); + } catch (e) { + log.error('error while parsing actor properties text', e); + } + }; + + /** + * @param actor - the actor to add the properties to + * @param properties - the properties to add to the actor's properties + */ + private readonly insertProperties = (actor: Actor, properties: Record) => { + if (actor.properties == null) { + actor.properties = properties; + } else { + for (const key in properties) { + actor.properties[key] = properties[key]; + } + } + }; + + private readonly boxEnd = () => { + this.state.records.currentBox = undefined; + }; + + public addDetails = (actorId: string, text: { text: string }) => { + // find the actor + const actor = this.getActor(actorId); + const elem = document.getElementById(text.text)!; + + // JSON.parse the text + try { + const text = elem.innerHTML; + const details = JSON.parse(text); + // add the deserialized text to the actor's property field. + if (details.properties) { + this.insertProperties(actor, details.properties); + } + + if (details.links) { + this.insertLinks(actor, details.links); + } + } catch (e) { + log.error('error while parsing actor details text', e); + } + }; + + public getActorProperty = (actor: Actor, key: string) => { + if (actor?.properties !== undefined) { + return actor.properties[key]; + } + + return undefined; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-redundant-type-constituents + public apply = (param: any | AddMessageParams | AddMessageParams[]) => { + if (Array.isArray(param)) { + param.forEach((item) => { + this.apply(item); + }); + } else { + switch (param.type) { + case 'sequenceIndex': + this.state.records.messages.push({ + from: undefined, + to: undefined, + message: { + start: param.sequenceIndex, + step: param.sequenceIndexStep, + visible: param.sequenceVisible, + }, + wrap: false, + type: param.signalType, + }); + break; + case 'addParticipant': + this.addActor(param.actor, param.actor, param.description, param.draw); + break; + case 'createParticipant': + if (this.state.records.actors.has(param.actor)) { + throw new Error( + "It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior" + ); + } + this.state.records.lastCreated = param.actor; + this.addActor(param.actor, param.actor, param.description, param.draw); + this.state.records.createdActors.set(param.actor, this.state.records.messages.length); + break; + case 'destroyParticipant': + this.state.records.lastDestroyed = param.actor; + this.state.records.destroyedActors.set(param.actor, this.state.records.messages.length); + break; + case 'activeStart': + this.addSignal(param.actor, undefined, undefined, param.signalType); + break; + case 'activeEnd': + this.addSignal(param.actor, undefined, undefined, param.signalType); + break; + case 'addNote': + this.addNote(param.actor, param.placement, param.text); + break; + case 'addLinks': + this.addLinks(param.actor, param.text); + break; + case 'addALink': + this.addALink(param.actor, param.text); + break; + case 'addProperties': + this.addProperties(param.actor, param.text); + break; + case 'addDetails': + this.addDetails(param.actor, param.text); + break; + case 'addMessage': + if (this.state.records.lastCreated) { + if (param.to !== this.state.records.lastCreated) { + throw new Error( + 'The created participant ' + + this.state.records.lastCreated.name + + ' does not have an associated creating message after its declaration. Please check the sequence diagram.' + ); + } else { + this.state.records.lastCreated = undefined; + } + } else if (this.state.records.lastDestroyed) { + if ( + param.to !== this.state.records.lastDestroyed && + param.from !== this.state.records.lastDestroyed + ) { + throw new Error( + 'The destroyed participant ' + + this.state.records.lastDestroyed.name + + ' does not have an associated destroying message after its declaration. Please check the sequence diagram.' + ); + } else { + this.state.records.lastDestroyed = undefined; + } + } + this.addSignal(param.from, param.to, param.msg, param.signalType, param.activate); + break; + case 'boxStart': + this.addBox(param.boxData); + break; + case 'boxEnd': + this.boxEnd(); + break; + case 'loopStart': + this.addSignal(undefined, undefined, param.loopText, param.signalType); + break; + case 'loopEnd': + this.addSignal(undefined, undefined, undefined, param.signalType); + break; + case 'rectStart': + this.addSignal(undefined, undefined, param.color, param.signalType); + break; + case 'rectEnd': + this.addSignal(undefined, undefined, undefined, param.signalType); + break; + case 'optStart': + this.addSignal(undefined, undefined, param.optText, param.signalType); + break; + case 'optEnd': + this.addSignal(undefined, undefined, undefined, param.signalType); + break; + case 'altStart': + this.addSignal(undefined, undefined, param.altText, param.signalType); + break; + case 'else': + this.addSignal(undefined, undefined, param.altText, param.signalType); + break; + case 'altEnd': + this.addSignal(undefined, undefined, undefined, param.signalType); + break; + case 'setAccTitle': + setAccTitle(param.text); + break; + case 'parStart': + this.addSignal(undefined, undefined, param.parText, param.signalType); + break; + case 'and': + this.addSignal(undefined, undefined, param.parText, param.signalType); + break; + case 'parEnd': + this.addSignal(undefined, undefined, undefined, param.signalType); + break; + case 'criticalStart': + this.addSignal(undefined, undefined, param.criticalText, param.signalType); + break; + case 'option': + this.addSignal(undefined, undefined, param.optionText, param.signalType); + break; + case 'criticalEnd': + this.addSignal(undefined, undefined, undefined, param.signalType); + break; + case 'breakStart': + this.addSignal(undefined, undefined, param.breakText, param.signalType); + break; + case 'breakEnd': + this.addSignal(undefined, undefined, undefined, param.signalType); + break; + } + } + }; + + public getAccTitle = getAccTitle; + public getDiagramTitle = getDiagramTitle; + public setDiagramTitle = setDiagramTitle; + public getConfig = () => getConfig().sequence; + public setAccTitle = setAccTitle; + public setAccDescription = setAccDescription; + public getAccDescription = getAccDescription; +} diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js index fde813cef..474668edf 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js +++ b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.spec.js @@ -3,6 +3,7 @@ import { setSiteConfig } from '../../diagram-api/diagramAPI.js'; import mermaidAPI from '../../mermaidAPI.js'; import { Diagram } from '../../Diagram.js'; import { addDiagrams } from '../../diagram-api/diagram-orchestration.js'; +import { SequenceDB } from './sequenceDb.js'; beforeAll(async () => { // Is required to load the sequence diagram @@ -97,8 +98,8 @@ let diagram; describe('more than one sequence diagram', () => { it('should not have duplicated messages', async () => { const diagram1 = await Diagram.fromText(` - sequenceDiagram - Alice->Bob:Hello Bob, how are you? + sequenceDiagram + Alice->Bob:Hello Bob, how are you? Bob-->Alice: I am good thanks!`); expect(diagram1.db.getMessages()).toMatchInlineSnapshot(` [ @@ -2071,3 +2072,27 @@ ${prop}-->>A: Hello, how are you?`) ).resolves.toBeDefined(); }); }); + +describe('sequence db class', () => { + let sequenceDb; + beforeEach(() => { + sequenceDb = new SequenceDB(); + }); + // This is to ensure that functions used in sequence JISON are exposed as function from SequenceDB + it('should have functions used in sequence JISON as own property', () => { + const functionsUsedInParser = [ + 'apply', + 'parseBoxData', + 'LINETYPE', + 'setDiagramTitle', + 'setAccTitle', + 'setAccDescription', + 'parseMessage', + 'PLACEMENT', + ]; + + for (const fun of functionsUsedInParser) { + expect(Object.hasOwn(sequenceDb, fun)).toBe(true); + } + }); +}); diff --git a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.ts b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.ts index f8d71c95e..80ccda5f9 100644 --- a/packages/mermaid/src/diagrams/sequence/sequenceDiagram.ts +++ b/packages/mermaid/src/diagrams/sequence/sequenceDiagram.ts @@ -1,13 +1,20 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; // @ts-ignore: JISON doesn't support types import parser from './parser/sequenceDiagram.jison'; -import db from './sequenceDb.js'; +import { SequenceDB } from './sequenceDb.js'; import styles from './styles.js'; import renderer from './sequenceRenderer.js'; +let db: SequenceDB; + export const diagram: DiagramDefinition = { parser, - db, + get db() { + if (!db) { + db = new SequenceDB(); + } + return db; + }, renderer, styles, init: ({ wrap }) => {