diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3ddf86ea5..1d1724a5e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -51,18 +51,10 @@ body: label: Setup description: |- Please fill out the below info. - Note that you only need to fill out one and not both sections. + Note that you only need to fill out the relevant section value: |- - **Desktop** - - - OS and Version: [Windows, Linux, Mac, ...] + - Mermaid version: - Browser and Version: [Chrome, Edge, Firefox] - - **Smartphone** - - - Device: [Samsung, iPhone, ...] - - OS and Version: [Android, iOS, ...] - - Browser and Version: [Chrome, Safari, ...] - type: textarea attributes: label: Additional Context diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6397e5305..6c01ba1b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,14 @@ jobs: run: | pnpm run ci --coverage + - name: Run ganttDb tests using California timezone + env: + # Makes sure that gantt db works even in a timezone that has daylight savings + # since some days have 25 hours instead of 24. + TZ: America/Los_Angeles + run: | + pnpm exec vitest run ./packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts + - name: Upload Coverage to Coveralls # it feels a bit weird to use @master, but that's what the docs use # (coveralls also doesn't publish a @v1 we can use) diff --git a/.lycheeignore b/.lycheeignore index 79cf4428b..4c781f6a0 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -9,8 +9,11 @@ https://mkdocs.org/ https://osawards.com/javascript/#nominees https://osawards.com/javascript/2019 -# Timeout error, maybe Twitter has anti-bot defences against GitHub's CI servers? +# Timeout error, maybe Twitter has anti-bot defenses against GitHub's CI servers? https://twitter.com/mermaidjs_ # Don't check files that are generated during the build via `pnpm docs:code` packages/mermaid/src/docs/config/setup/* + +# Network error: 502, since few days +https://bundlephobia.com/ diff --git a/cSpell.json b/cSpell.json index d5fbcfe7c..6f35a0142 100644 --- a/cSpell.json +++ b/cSpell.json @@ -19,6 +19,7 @@ "brkt", "brolin", "brotli", + "città", "classdef", "codedoc", "colour", diff --git a/cypress/helpers/util.js b/cypress/helpers/util.js index 533cca499..7ec960b97 100644 --- a/cypress/helpers/util.js +++ b/cypress/helpers/util.js @@ -22,7 +22,7 @@ export const mermaidUrl = (graphStr, options, api) => { return url; }; -export const imgSnapshotTest = (graphStr, _options, api = false, validation) => { +export const imgSnapshotTest = (graphStr, _options = {}, api = false, validation = undefined) => { cy.log(_options); const options = Object.assign(_options); if (!options.fontFamily) { diff --git a/cypress/integration/rendering/classDiagram-v2.spec.js b/cypress/integration/rendering/classDiagram-v2.spec.js index 9536a074d..71810cfa4 100644 --- a/cypress/integration/rendering/classDiagram-v2.spec.js +++ b/cypress/integration/rendering/classDiagram-v2.spec.js @@ -13,7 +13,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('1: should render a simple class diagram', () => { @@ -47,7 +46,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('2: should render a simple class diagrams with cardinality', () => { @@ -76,7 +74,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('should render a simple class diagram with different visibilities', () => { @@ -94,7 +91,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('should render multiple class diagrams', () => { @@ -147,7 +143,6 @@ describe('Class diagram V2', () => { ], { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('4: should render a simple class diagram with comments', () => { @@ -177,7 +172,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('5: should render a simple class diagram with abstract method', () => { @@ -189,7 +183,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('6: should render a simple class diagram with static method', () => { @@ -201,7 +194,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('7: should render a simple class diagram with Generic class', () => { @@ -221,7 +213,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('8: should render a simple class diagram with Generic class and relations', () => { @@ -242,7 +233,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('9: should render a simple class diagram with clickable link', () => { @@ -264,7 +254,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('10: should render a simple class diagram with clickable callback', () => { @@ -286,7 +275,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('11: should render a simple class diagram with return type on method', () => { @@ -301,7 +289,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('12: should render a simple class diagram with generic types', () => { @@ -317,7 +304,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('13: should render a simple class diagram with css classes applied', () => { @@ -335,7 +321,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('14: should render a simple class diagram with css classes applied directly', () => { @@ -351,7 +336,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('15: should render a simple class diagram with css classes applied two multiple classes', () => { @@ -365,7 +349,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('16a: should render a simple class diagram with static field', () => { @@ -378,7 +361,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('16b: should handle the direction statement with TB', () => { @@ -403,7 +385,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('18: should handle the direction statement with LR', () => { @@ -428,7 +409,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('17a: should handle the direction statement with BT', () => { imgSnapshotTest( @@ -452,7 +432,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('17b: should handle the direction statement with RL', () => { imgSnapshotTest( @@ -476,7 +455,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('18: should render a simple class diagram with notes', () => { @@ -493,7 +471,6 @@ describe('Class diagram V2', () => { `, { logLevel: 1, flowchart: { htmlLabels: false } } ); - cy.get('svg'); }); it('1433: should render a simple class with a title', () => { @@ -503,8 +480,72 @@ title: simple class diagram --- classDiagram-v2 class Class10 -`, - {} +` + ); + }); + + it('should render a class with text label', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + C1 --> C2` + ); + }); + + it('should render two classes with text labels', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] + class C2["Class 2 with chars @?"] + C1 --> C2` + ); + }); + it('should render a class with a text label, members and annotation', () => { + imgSnapshotTest( + `classDiagram + class C1["Class 1 with text label"] { + <<interface>> + +member1 + } + C1 --> C2` + ); + }); + it('should render multiple classes with same text labels', () => { + imgSnapshotTest( + `classDiagram +class C1["Class with text label"] +class C2["Class with text label"] +class C3["Class with text label"] +C1 --> C2 +C3 ..> C2 + ` + ); + }); + it('should render classes with different text labels', () => { + imgSnapshotTest( + `classDiagram +class C1["OneWord"] +class C2["With, Comma"] +class C3["With (Brackets)"] +class C4["With [Brackets]"] +class C5["With {Brackets}"] +class C7["With 1 number"] +class C8["With . period..."] +class C9["With - dash"] +class C10["With _ underscore"] +class C11["With ' single quote"] +class C12["With ~!@#$%^&*()_+=-/?"] +class C13["With Città foreign language"] + ` + ); + }); + + it('should render classLabel if class has already been defined earlier', () => { + imgSnapshotTest( + `classDiagram + Animal <|-- Duck + class Duck["Duck with text label"] +` ); }); }); diff --git a/cypress/platform/class.html b/cypress/platform/class.html index 052dd18b9..2f853bbc1 100644 --- a/cypress/platform/class.html +++ b/cypress/platform/class.html @@ -12,7 +12,6 @@ -
-%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
 graph TB
       a --> b
       a --> c
       b --> d
       c --> d
     
-
-flowchart-elk LR
-  subgraph A
-  a --> b
-  end
-  subgraph B
-  b
-  end
-    
-
-%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
-flowchart TB
-  %% I could not figure out how to use double quotes in labels in Mermaid
-  subgraph ibm[IBM Espresso CPU]
-    core0[IBM PowerPC Broadway Core 0]
-    core1[IBM PowerPC Broadway Core 1]
-    core2[IBM PowerPC Broadway Core 2]
 
-    rom[16 KB ROM]
-
-    core0 --- core2
-
-    rom --> core2
-  end
-
-  subgraph amd[AMD Latte GPU]
-    mem[Memory & I/O Bridge]
-    dram[DRAM Controller]
-    edram[32 MB EDRAM MEM1]
-    rom[512 B SEEPROM]
-
-    sata[SATA IF]
-    exi[EXI]
-
-    subgraph gx[GX]
-      sram[3 MB 1T-SRAM]
-    end
-
-    radeon[AMD Radeon R7xx GX2]
-
-    mem --- gx
-    mem --- radeon
-
-    rom --- mem
-
-    mem --- sata
-    mem --- exi
-
-    dram --- sata
-    dram --- exi
-  end
-
-  ddr3[2 GB DDR3 RAM MEM2]
-
-  mem --- ddr3
-  dram --- ddr3
-  edram --- ddr3
-
-  core1 --- mem
-
-  exi --- rtc
-  rtc{{rtc}}
-
-
-
-flowchart TB
-  %% I could not figure out how to use double quotes in labels in Mermaid
-  subgraph ibm[IBM Espresso CPU]
-    core0[IBM PowerPC Broadway Core 0]
-    core1[IBM PowerPC Broadway Core 1]
-    core2[IBM PowerPC Broadway Core 2]
-
-    rom[16 KB ROM]
-
-    core0 --- core2
-
-    rom --> core2
-  end
-
-  subgraph amd[AMD Latte GPU]
-    mem[Memory & I/O Bridge]
-    dram[DRAM Controller]
-    edram[32 MB EDRAM MEM1]
-    rom[512 B SEEPROM]
-
-    sata[SATA IF]
-    exi[EXI]
-
-    subgraph gx[GX]
-      sram[3 MB 1T-SRAM]
-    end
-
-    radeon[AMD Radeon R7xx GX2]
-
-    mem --- gx
-    mem --- radeon
-
-    rom --- mem
-
-    mem --- sata
-    mem --- exi
-
-    dram --- sata
-    dram --- exi
-  end
-
-  ddr3[2 GB DDR3 RAM MEM2]
-
-  mem --- ddr3
-  dram --- ddr3
-  edram --- ddr3
-
-  core1 --- mem
-
-  exi --- rtc
-  rtc{{rtc}}
-
-
-   -
-      flowchart LR
-  B1 --be be--x B2
-  B1 --bo bo--o B3
-  subgraph Ugge
-      B2
-      B3
-      subgraph inner
-          B4
-          B5
-      end
-      subgraph inner2
-        subgraph deeper
-          C4
-          C5
-        end
-        C6
-      end
-
-      B4 --> C4
-
-      B3 -- X --> B4
-      B2 --> inner
-
-      C4 --> C5
-  end
-
-  subgraph outer
-      B6
-  end
-  B6 --> B5
-  
-
-sequenceDiagram
-    Customer->>+Stripe: Makes a payment request
-    Stripe->>+Bank: Forwards the payment request to the bank
-    Bank->>+Customer: Asks for authorization
-    Customer->>+Bank: Provides authorization
-    Bank->>+Stripe: Sends a response with payment details
-    Stripe->>+Merchant: Sends a notification of payment receipt
-    Merchant->>+Stripe: Confirms the payment
-    Stripe->>+Customer: Sends a confirmation of payment
-    Customer->>+Merchant: Receives goods or services
-        
-
-      gantt
-        title Style today marker (vertical line should be 5px wide and half-transparent blue)
-        dateFormat YYYY-MM-DD
-        axisFormat %d
-        todayMarker stroke-width:5px,stroke:#00f,opacity:0.5
-        section Section1
-        Today: 1, -1h
-    
+
``` -It can also be be turned on via the diagram code as in the diagram: +It can also be turned on via the diagram code as in the diagram: ```mermaid-example sequenceDiagram diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index dda5a5162..871ff163b 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -7,8 +7,8 @@ "types": "./dist/mermaid.d.ts", "exports": { ".": { - "import": "./dist/mermaid.core.mjs", - "types": "./dist/mermaid.d.ts" + "types": "./dist/mermaid.d.ts", + "import": "./dist/mermaid.core.mjs" }, "./*": "./*" }, @@ -56,13 +56,13 @@ "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.1.0", "d3": "^7.4.0", - "dagre-d3-es": "7.0.8", + "dagre-d3-es": "7.0.9", + "dayjs": "^1.11.7", "dompurify": "2.4.3", "elkjs": "^0.8.2", "katex": "^0.15.2", "khroma": "^2.0.0", "lodash-es": "^4.17.21", - "moment-mini": "^2.29.4", "non-layered-tidy-tree-layout": "^2.0.2", "stylis": "^4.1.2", "ts-dedent": "^2.2.0", diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index c835ee440..39ab3f4d9 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -264,6 +264,10 @@ export interface ClassDiagramConfig extends BaseDiagramConfig { padding?: number; textHeight?: number; defaultRenderer?: string; + nodeSpacing?: number; + rankSpacing?: number; + diagramPadding?: number; + htmlLabels?: boolean; } export interface JourneyDiagramConfig extends BaseDiagramConfig { @@ -295,6 +299,7 @@ export interface TimelineDiagramConfig extends BaseDiagramConfig { leftMargin?: number; width?: number; height?: number; + padding?: number; boxMargin?: number; boxTextMargin?: number; noteMargin?: number; @@ -311,6 +316,7 @@ export interface TimelineDiagramConfig extends BaseDiagramConfig { sectionFills?: string[]; sectionColours?: string[]; disableMulticolor?: boolean; + useMaxWidth?: boolean; } export interface GanttDiagramConfig extends BaseDiagramConfig { diff --git a/packages/mermaid/src/dagre-wrapper/nodes.js b/packages/mermaid/src/dagre-wrapper/nodes.js index 5bd18d077..49b96b685 100644 --- a/packages/mermaid/src/dagre-wrapper/nodes.js +++ b/packages/mermaid/src/dagre-wrapper/nodes.js @@ -772,7 +772,7 @@ const class_box = (parent, node) => { maxWidth += interfaceBBox.width; } - let classTitleString = node.classData.id; + let classTitleString = node.classData.label; if (node.classData.type !== undefined && node.classData.type !== '') { if (getConfig().flowchart.htmlLabels) { @@ -927,61 +927,6 @@ const class_box = (parent, node) => { ); verticalPos += classTitleBBox.height + rowPadding; }); - // - // let bbox; - // if (evaluate(getConfig().flowchart.htmlLabels)) { - // const div = interfaceLabel.children[0]; - // const dv = select(interfaceLabel); - // bbox = div.getBoundingClientRect(); - // dv.attr('width', bbox.width); - // dv.attr('height', bbox.height); - // } - // bbox = labelContainer.getBBox(); - - // log.info('Text 2', text2); - // const textRows = text2.slice(1, text2.length); - // let titleBox = text.getBBox(); - // const descr = label - // .node() - // .appendChild(createLabel(textRows.join('
'), node.labelStyle, true, true)); - - // if (evaluate(getConfig().flowchart.htmlLabels)) { - // const div = descr.children[0]; - // const dv = select(descr); - // bbox = div.getBoundingClientRect(); - // dv.attr('width', bbox.width); - // dv.attr('height', bbox.height); - // } - // // bbox = label.getBBox(); - // // log.info(descr); - // select(descr).attr( - // 'transform', - // 'translate( ' + - // // (titleBox.width - bbox.width) / 2 + - // (bbox.width > titleBox.width ? 0 : (titleBox.width - bbox.width) / 2) + - // ', ' + - // (titleBox.height + halfPadding + 5) + - // ')' - // ); - // select(text).attr( - // 'transform', - // 'translate( ' + - // // (titleBox.width - bbox.width) / 2 + - // (bbox.width < titleBox.width ? 0 : -(titleBox.width - bbox.width) / 2) + - // ', ' + - // 0 + - // ')' - // ); - // // Get the size of the label - - // // Bounding box for title and text - // bbox = label.node().getBBox(); - - // // Center the label - // label.attr( - // 'transform', - // 'translate(' + -bbox.width / 2 + ', ' + (-bbox.height / 2 - halfPadding + 3) + ')' - // ); rect .attr('class', 'outer title-state') @@ -990,13 +935,6 @@ const class_box = (parent, node) => { .attr('width', maxWidth + node.padding) .attr('height', maxHeight + node.padding); - // innerLine - // .attr('class', 'divider') - // .attr('x1', -bbox.width / 2 - halfPadding) - // .attr('x2', bbox.width / 2 + halfPadding) - // .attr('y1', -bbox.height / 2 - halfPadding + titleBox.height + halfPadding) - // .attr('y2', -bbox.height / 2 - halfPadding + titleBox.height + halfPadding); - updateNodeBounds(node, rect); node.intersect = function (point) { diff --git a/packages/mermaid/src/diagram-api/detectType.ts b/packages/mermaid/src/diagram-api/detectType.ts index 6ffe5df85..0d26487cf 100644 --- a/packages/mermaid/src/diagram-api/detectType.ts +++ b/packages/mermaid/src/diagram-api/detectType.ts @@ -46,9 +46,24 @@ export const detectType = function (text: string, config?: MermaidConfig): strin } } - throw new UnknownDiagramError(`No diagram type detected for text: ${text}`); + throw new UnknownDiagramError( + `No diagram type detected matching given configuration for text: ${text}` + ); }; +/** + * Registers lazy-loaded diagrams to Mermaid. + * + * The diagram function is loaded asynchronously, so that diagrams are only loaded + * if the diagram is detected. + * + * @remarks + * Please note that the order of diagram detectors is important. + * The first detector to return `true` is the diagram that will be loaded + * and used, so put more specific detectors at the beginning! + * + * @param diagrams - Diagrams to lazy load, and their detectors, in order of importance. + */ export const registerLazyLoadedDiagrams = (...diagrams: ExternalDiagramDefinition[]) => { for (const { id, detector, loader } of diagrams) { addDetector(id, detector, loader); @@ -97,4 +112,6 @@ export const addDetector = (key: string, detector: DiagramDetector, loader?: Dia log.debug(`Detector with key ${key} added${loader ? ' with loader' : ''}`); }; -export const getDiagramLoader = (key: string) => detectors[key].loader; +export const getDiagramLoader = (key: string) => { + return detectors[key].loader; +}; diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts new file mode 100644 index 000000000..81909fe5e --- /dev/null +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts @@ -0,0 +1,82 @@ +import { it, describe, expect } from 'vitest'; +import { detectType } from './detectType'; +import { addDiagrams } from './diagram-orchestration'; + +describe('diagram-orchestration', () => { + it('should register diagrams', () => { + expect(() => detectType('graph TD; A-->B')).toThrow(); + addDiagrams(); + expect(detectType('graph TD; A-->B')).toBe('flowchart'); + }); + + describe('proper diagram types should be detetced', () => { + beforeAll(() => { + addDiagrams(); + }); + + it.each([ + { text: 'graph TD;', expected: 'flowchart' }, + { text: 'flowchart TD;', expected: 'flowchart-v2' }, + { text: 'flowchart-v2 TD;', expected: 'flowchart-v2' }, + { text: 'flowchart-elk TD;', expected: 'flowchart-elk' }, + { text: 'error', expected: 'error' }, + { text: 'C4Context;', expected: 'c4' }, + { text: 'classDiagram', expected: 'class' }, + { text: 'classDiagram-v2', expected: 'classDiagram' }, + { text: 'erDiagram', expected: 'er' }, + { text: 'journey', expected: 'journey' }, + { text: 'gantt', expected: 'gantt' }, + { text: 'pie', expected: 'pie' }, + { text: 'requirementDiagram', expected: 'requirement' }, + { text: 'info', expected: 'info' }, + { text: 'sequenceDiagram', expected: 'sequence' }, + { text: 'mindmap', expected: 'mindmap' }, + { text: 'timeline', expected: 'timeline' }, + { text: 'gitGraph', expected: 'gitGraph' }, + { text: 'stateDiagram', expected: 'state' }, + { text: 'stateDiagram-v2', expected: 'stateDiagram' }, + ])( + 'should $text be detected as $expected', + ({ text, expected }: { text: string; expected: string }) => { + expect(detectType(text)).toBe(expected); + } + ); + + it('should detect proper flowchart type based on config', () => { + // graph & dagre-d3 => flowchart + expect(detectType('graph TD; A-->B')).toBe('flowchart'); + // graph & dagre-d3 => flowchart + expect(detectType('graph TD; A-->B', { flowchart: { defaultRenderer: 'dagre-d3' } })).toBe( + 'flowchart' + ); + // flowchart & dagre-d3 => error + expect(() => + detectType('flowchart TD; A-->B', { flowchart: { defaultRenderer: 'dagre-d3' } }) + ).toThrowErrorMatchingInlineSnapshot( + '"No diagram type detected matching given configuration for text: flowchart TD; A-->B"' + ); + + // graph & dagre-wrapper => flowchart-v2 + expect( + detectType('graph TD; A-->B', { flowchart: { defaultRenderer: 'dagre-wrapper' } }) + ).toBe('flowchart-v2'); + // flowchart ==> flowchart-v2 + expect(detectType('flowchart TD; A-->B')).toBe('flowchart-v2'); + // flowchart && dagre-wrapper ==> flowchart-v2 + expect( + detectType('flowchart TD; A-->B', { flowchart: { defaultRenderer: 'dagre-wrapper' } }) + ).toBe('flowchart-v2'); + // flowchart && elk ==> flowchart-elk + expect(detectType('flowchart TD; A-->B', { flowchart: { defaultRenderer: 'elk' } })).toBe( + 'flowchart-elk' + ); + }); + + it('should not detect flowchart if pie contains flowchart', () => { + expect( + detectType(`pie title: "flowchart" + flowchart: 1 "pie" pie: 2 "pie"`) + ).toBe('pie'); + }); + }); +}); diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index d06ce846a..73bfcf084 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -45,7 +45,7 @@ export const addDiagrams = () => { throw new Error( 'Diagrams beginning with --- are not valid. ' + 'If you were trying to use a YAML front-matter, please ensure that ' + - "you've correctly opened and closed the YAML front-matter with unindented `---` blocks" + "you've correctly opened and closed the YAML front-matter with un-indented `---` blocks" ); }, }, @@ -55,25 +55,26 @@ export const addDiagrams = () => { return text.toLowerCase().trimStart().startsWith('---'); } ); + // Ordering of detectors is important. The first one to return true will be used. registerLazyLoadedDiagrams( error, c4, - classDiagram, classDiagramV2, + classDiagram, er, gantt, info, pie, requirement, sequence, - flowchart, - flowchartV2, flowchartElk, + flowchartV2, + flowchart, mindmap, timeline, git, - state, stateV2, + state, journey ); }; diff --git a/packages/mermaid/src/diagram-api/diagramAPI.spec.ts b/packages/mermaid/src/diagram-api/diagramAPI.spec.ts index 9e04c861f..4bdcc5e39 100644 --- a/packages/mermaid/src/diagram-api/diagramAPI.spec.ts +++ b/packages/mermaid/src/diagram-api/diagramAPI.spec.ts @@ -3,6 +3,7 @@ import { getDiagram, registerDiagram } from './diagramAPI'; import { addDiagrams } from './diagram-orchestration'; import { DiagramDetector } from './types'; import { getDiagramFromText } from '../Diagram'; +import { it, describe, expect, beforeAll } from 'vitest'; addDiagrams(); beforeAll(async () => { @@ -15,13 +16,17 @@ describe('DiagramAPI', () => { }); it('should throw error if diagram is not defined', () => { - expect(() => getDiagram('loki')).toThrow(); + expect(() => getDiagram('loki')).toThrowErrorMatchingInlineSnapshot( + '"Diagram loki not found."' + ); }); it('should handle diagram registrations', () => { - expect(() => getDiagram('loki')).toThrow(); - expect(() => detectType('loki diagram')).toThrow( - 'No diagram type detected for text: loki diagram' + expect(() => getDiagram('loki')).toThrowErrorMatchingInlineSnapshot( + '"Diagram loki not found."' + ); + expect(() => detectType('loki diagram')).toThrowErrorMatchingInlineSnapshot( + '"No diagram type detected matching given configuration for text: loki diagram"' ); const detector: DiagramDetector = (str: string) => { return str.match('loki') !== null; diff --git a/packages/mermaid/src/diagram.spec.ts b/packages/mermaid/src/diagram.spec.ts index a862c7936..5a4718d0b 100644 --- a/packages/mermaid/src/diagram.spec.ts +++ b/packages/mermaid/src/diagram.spec.ts @@ -61,8 +61,8 @@ Expecting 'TXT', got 'NEWLINE'" }); test('should throw the right error for unregistered diagrams', async () => { - await expect(getDiagramFromText('thor TD; A-->B')).rejects.toThrowError( - 'No diagram type detected for text: thor TD; A-->B' + await expect(getDiagramFromText('thor TD; A-->B')).rejects.toThrowErrorMatchingInlineSnapshot( + '"No diagram type detected matching given configuration for text: thor TD; A-->B"' ); }); }); diff --git a/packages/mermaid/src/diagrams/class/classDb.js b/packages/mermaid/src/diagrams/class/classDb.ts similarity index 66% rename from packages/mermaid/src/diagrams/class/classDb.js rename to packages/mermaid/src/diagrams/class/classDb.ts index 2c6690e39..48ef7ccbe 100644 --- a/packages/mermaid/src/diagrams/class/classDb.js +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -1,4 +1,5 @@ -import { select } from 'd3'; +// @ts-expect-error - d3 types issue +import { select, Selection } from 'd3'; import { log } from '../../logger'; import * as configApi from '../../config'; import common from '../common/common'; @@ -13,44 +14,54 @@ import { setDiagramTitle, getDiagramTitle, } from '../../commonDb'; +import { ClassRelation, ClassNode, ClassNote, ClassMap } from './classTypes'; -const MERMAID_DOM_ID_PREFIX = 'classid-'; +const MERMAID_DOM_ID_PREFIX = 'classId-'; -let relations = []; -let classes = {}; -let notes = []; +let relations: ClassRelation[] = []; +let classes: ClassMap = {}; +let notes: ClassNote[] = []; let classCounter = 0; -let funs = []; +let functions: any[] = []; -const sanitizeText = (txt) => common.sanitizeText(txt, configApi.getConfig()); +const sanitizeText = (txt: string) => common.sanitizeText(txt, configApi.getConfig()); -export const parseDirective = function (statement, context, type) { +export const parseDirective = function (statement: string, context: string, type: string) { + // @ts-ignore Don't wanna mess it up mermaidAPI.parseDirective(this, statement, context, type); }; -const splitClassNameAndType = function (id) { +const splitClassNameAndType = function (id: string) { let genericType = ''; let className = id; if (id.indexOf('~') > 0) { - let split = id.split('~'); - className = split[0]; - - genericType = common.sanitizeText(split[1], configApi.getConfig()); + const split = id.split('~'); + className = sanitizeText(split[0]); + genericType = sanitizeText(split[1]); } return { className: className, type: genericType }; }; +export const setClassLabel = function (id: string, label: string) { + if (label) { + label = sanitizeText(label); + } + + const { className } = splitClassNameAndType(id); + classes[className].label = label; +}; + /** * Function called by parser when a node definition has been found. * - * @param id + * @param id - Id of the class to add * @public */ -export const addClass = function (id) { - let classId = splitClassNameAndType(id); +export const addClass = function (id: string) { + const classId = splitClassNameAndType(id); // Only add class if not exists if (classes[classId.className] !== undefined) { return; @@ -59,12 +70,13 @@ export const addClass = function (id) { classes[classId.className] = { id: classId.className, type: classId.type, + label: classId.className, cssClasses: [], methods: [], members: [], annotations: [], domId: MERMAID_DOM_ID_PREFIX + classId.className + '-' + classCounter, - }; + } as ClassNode; classCounter++; }; @@ -72,35 +84,33 @@ export const addClass = function (id) { /** * Function to lookup domId from id in the graph definition. * - * @param id + * @param id - class ID to lookup * @public */ -export const lookUpDomId = function (id) { - const classKeys = Object.keys(classes); - for (const classKey of classKeys) { - if (classes[classKey].id === id) { - return classes[classKey].domId; - } +export const lookUpDomId = function (id: string): string { + if (id in classes) { + return classes[id].domId; } + throw new Error('Class not found: ' + id); }; export const clear = function () { relations = []; classes = {}; notes = []; - funs = []; - funs.push(setupToolTips); + functions = []; + functions.push(setupToolTips); commonClear(); }; -export const getClass = function (id) { +export const getClass = function (id: string) { return classes[id]; }; export const getClasses = function () { return classes; }; -export const getRelations = function () { +export const getRelations = function (): ClassRelation[] { return relations; }; @@ -108,7 +118,7 @@ export const getNotes = function () { return notes; }; -export const addRelation = function (relation) { +export const addRelation = function (relation: ClassRelation) { log.debug('Adding relation: ' + JSON.stringify(relation)); addClass(relation.id1); addClass(relation.id2); @@ -133,11 +143,11 @@ export const addRelation = function (relation) { * 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 + * @param className - The class name + * @param annotation - The name of the annotation without any brackets * @public */ -export const addAnnotation = function (className, annotation) { +export const addAnnotation = function (className: string, annotation: string) { const validatedClassName = splitClassNameAndType(className).className; classes[validatedClassName].annotations.push(annotation); }; @@ -145,13 +155,13 @@ export const addAnnotation = function (className, 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 + * @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, member) { +export const addMember = function (className: string, member: string) { const validatedClassName = splitClassNameAndType(className).className; const theClass = classes[validatedClassName]; @@ -161,7 +171,6 @@ export const addMember = function (className, member) { if (memberString.startsWith('<<') && memberString.endsWith('>>')) { // Remove leading and trailing brackets - // theClass.annotations.push(memberString.substring(2, memberString.length - 2)); theClass.annotations.push(sanitizeText(memberString.substring(2, memberString.length - 2))); } else if (memberString.indexOf(')') > 0) { theClass.methods.push(sanitizeText(memberString)); @@ -171,14 +180,14 @@ export const addMember = function (className, member) { } }; -export const addMembers = function (className, members) { +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, className) { +export const addNote = function (text: string, className: string) { const note = { id: `note${notes.length}`, class: className, @@ -187,21 +196,20 @@ export const addNote = function (text, className) { notes.push(note); }; -export const cleanupLabel = function (label) { - if (label.substring(0, 1) === ':') { - return common.sanitizeText(label.substr(1).trim(), configApi.getConfig()); - } else { - return sanitizeText(label.trim()); +export const cleanupLabel = function (label: string) { + if (label.startsWith(':')) { + label = label.substring(1); } + return sanitizeText(label.trim()); }; /** * Called by parser when a special node is found, e.g. a clickable element. * - * @param ids Comma separated list of ids - * @param className Class to add + * @param ids - Comma separated list of ids + * @param className - Class to add */ -export const setCssClass = function (ids, className) { +export const setCssClass = function (ids: string, className: string) { ids.split(',').forEach(function (_id) { let id = _id; if (_id[0].match(/\d/)) { @@ -216,28 +224,27 @@ export const setCssClass = function (ids, className) { /** * 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 + * @param ids - Comma separated list of ids + * @param tooltip - Tooltip to add */ -const setTooltip = function (ids, tooltip) { - const config = configApi.getConfig(); +const setTooltip = function (ids: string, tooltip?: string) { ids.split(',').forEach(function (id) { if (tooltip !== undefined) { - classes[id].tooltip = common.sanitizeText(tooltip, config); + classes[id].tooltip = sanitizeText(tooltip); } }); }; -export const getTooltip = function (id) { +export const getTooltip = function (id: string) { return classes[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 + * @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, linkStr, target) { +export const setLink = function (ids: string, linkStr: string, target: string) { const config = configApi.getConfig(); ids.split(',').forEach(function (_id) { let id = _id; @@ -261,11 +268,11 @@ export const setLink = function (ids, linkStr, target) { /** * 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 + * @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, functionName, functionArgs) { +export const setClickEvent = function (ids: string, functionName: string, functionArgs: string) { ids.split(',').forEach(function (id) { setClickFunc(id, functionName, functionArgs); classes[id].haveCallback = true; @@ -273,19 +280,19 @@ export const setClickEvent = function (ids, functionName, functionArgs) { setCssClass(ids, 'clickable'); }; -const setClickFunc = function (domId, functionName, functionArgs) { +const setClickFunc = function (domId: string, functionName: string, functionArgs: string) { const config = configApi.getConfig(); - let id = domId; - let elemId = lookUpDomId(id); - if (config.securityLevel !== 'loose') { return; } if (functionName === undefined) { return; } + + const id = domId; if (classes[id] !== undefined) { - let argList = []; + const elemId = lookUpDomId(id); + let argList: string[] = []; if (typeof functionArgs === 'string') { /* Splits functionArgs by ',', ignoring all ',' in double quoted strings */ argList = functionArgs.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/); @@ -305,7 +312,7 @@ const setClickFunc = function (domId, functionName, functionArgs) { argList.push(elemId); } - funs.push(function () { + functions.push(function () { const elem = document.querySelector(`[id="${elemId}"]`); if (elem !== null) { elem.addEventListener( @@ -320,8 +327,8 @@ const setClickFunc = function (domId, functionName, functionArgs) { } }; -export const bindFunctions = function (element) { - funs.forEach(function (fun) { +export const bindFunctions = function (element: Element) { + functions.forEach(function (fun) { fun(element); }); }; @@ -339,8 +346,10 @@ export const relationType = { LOLLIPOP: 4, }; -const setupToolTips = function (element) { - let tooltipElem = select('.mermaidTooltip'); +const setupToolTips = function (element: Element) { + let tooltipElem: Selection = + select('.mermaidTooltip'); + // @ts-ignore - _groups is a dynamic property if ((tooltipElem._groups || tooltipElem)[0][0] === null) { tooltipElem = select('body').append('div').attr('class', 'mermaidTooltip').style('opacity', 0); } @@ -350,12 +359,14 @@ const setupToolTips = function (element) { const nodes = svg.selectAll('g.node'); nodes .on('mouseover', function () { + // @ts-expect-error - select is not part of the d3 type definition const el = select(this); const title = el.attr('title'); - // Dont try to draw a tooltip if no data is provided + // 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'); @@ -368,15 +379,16 @@ const setupToolTips = function (element) { }) .on('mouseout', function () { tooltipElem.transition().duration(500).style('opacity', 0); + // @ts-expect-error - select is not part of the d3 type definition const el = select(this); el.classed('hover', false); }); }; -funs.push(setupToolTips); +functions.push(setupToolTips); let direction = 'TB'; const getDirection = () => direction; -const setDirection = (dir) => { +const setDirection = (dir: string) => { direction = dir; }; @@ -412,4 +424,5 @@ export default { lookUpDomId, setDiagramTitle, getDiagramTitle, + setClassLabel, }; diff --git a/packages/mermaid/src/diagrams/class/classDiagram.spec.js b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts similarity index 81% rename from packages/mermaid/src/diagrams/class/classDiagram.spec.js rename to packages/mermaid/src/diagrams/class/classDiagram.spec.ts index 04a8e9bf3..5ff193186 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.spec.js +++ b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts @@ -1,10 +1,11 @@ +// @ts-expect-error Jison doesn't export types import { parser } from './parser/classDiagram'; import classDb from './classDb'; -import { vi } from 'vitest'; +import { vi, describe, it, expect } from 'vitest'; const spyOn = vi.spyOn; describe('class diagram, ', function () { - describe('when parsing an info graph it', function () { + describe('when parsing a class diagram', function () { beforeEach(function () { parser.yy = classDb; }); @@ -541,7 +542,7 @@ foo() }); }); - describe('when fetching data from a classDiagram graph it', function () { + describe('when fetching data from a classDiagram it', function () { beforeEach(function () { parser.yy = classDb; parser.yy.clear(); @@ -946,4 +947,189 @@ foo() expect(classDb.setTooltip).toHaveBeenCalledWith('Class1', 'A tooltip'); }); }); + + describe('when parsing classDiagram with text labels', () => { + beforeEach(function () { + parser.yy = classDb; + parser.yy.clear(); + }); + + it('should parse a class with a text label', () => { + parser.parse(`classDiagram + class C1["Class 1 with text label"] + C1 --> C2 + `); + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + const c2 = classDb.getClass('C2'); + expect(c2.label).toBe('C2'); + }); + + it('should parse two classes with text labels', () => { + parser.parse(`classDiagram + class C1["Class 1 with text label"] + class C2["Class 2 with chars @?"] + C1 --> C2 + `); + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + const c2 = classDb.getClass('C2'); + expect(c2.label).toBe('Class 2 with chars @?'); + }); + + it('should parse a class with a text label and members', () => { + parser.parse(`classDiagram + class C1["Class 1 with text label"] { + +member1 + } + C1 --> C2 + `); + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + expect(c1.members.length).toBe(1); + expect(c1.members[0]).toBe('+member1'); + + const c2 = classDb.getClass('C2'); + expect(c2.label).toBe('C2'); + }); + + it('should parse a class with a text label, members and annotation', () => { + parser.parse(`classDiagram + class C1["Class 1 with text label"] { + <> + +member1 + } + C1 --> C2 + `); + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + expect(c1.members.length).toBe(1); + expect(c1.members[0]).toBe('+member1'); + expect(c1.annotations.length).toBe(1); + expect(c1.annotations[0]).toBe('interface'); + + const c2 = classDb.getClass('C2'); + expect(c2.label).toBe('C2'); + }); + + it('should parse a class with text label and css class shorthand', () => { + parser.parse(`classDiagram +class C1["Class 1 with text label"]:::styleClass { + +member1 +} +C1 --> C2 + `); + + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + expect(c1.cssClasses.length).toBe(1); + expect(c1.cssClasses[0]).toBe('styleClass'); + }); + + it('should parse a class with text label and css class', () => { + parser.parse(`classDiagram +class C1["Class 1 with text label"] { + +member1 +} +C1 --> C2 +cssClass "C1" styleClass + `); + + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + expect(c1.cssClasses.length).toBe(1); + expect(c1.cssClasses[0]).toBe('styleClass'); + }); + + it('should parse two classes with text labels and css classes', () => { + parser.parse(`classDiagram +class C1["Class 1 with text label"] { + +member1 +} +class C2["Long long long long long long long long long long label"] +C1 --> C2 +cssClass "C1,C2" styleClass + `); + + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + expect(c1.cssClasses.length).toBe(1); + expect(c1.cssClasses[0]).toBe('styleClass'); + + const c2 = classDb.getClass('C2'); + expect(c2.label).toBe('Long long long long long long long long long long label'); + expect(c2.cssClasses.length).toBe(1); + expect(c2.cssClasses[0]).toBe('styleClass'); + }); + + it('should parse two classes with text labels and css class shorthands', () => { + parser.parse(`classDiagram +class C1["Class 1 with text label"]:::styleClass1 { + +member1 +} +class C2["Class 2 !@#$%^&*() label"]:::styleClass2 +C1 --> C2 + `); + + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class 1 with text label'); + expect(c1.cssClasses.length).toBe(1); + expect(c1.cssClasses[0]).toBe('styleClass1'); + + const c2 = classDb.getClass('C2'); + expect(c2.label).toBe('Class 2 !@#$%^&*() label'); + expect(c2.cssClasses.length).toBe(1); + expect(c2.cssClasses[0]).toBe('styleClass2'); + }); + + it('should parse multiple classes with same text labels', () => { + parser.parse(`classDiagram +class C1["Class with text label"] +class C2["Class with text label"] +class C3["Class with text label"] +C1 --> C2 +C3 ..> C2 + `); + + const c1 = classDb.getClass('C1'); + expect(c1.label).toBe('Class with text label'); + + const c2 = classDb.getClass('C2'); + expect(c2.label).toBe('Class with text label'); + + const c3 = classDb.getClass('C3'); + expect(c3.label).toBe('Class with text label'); + }); + + it('should parse classes with different text labels', () => { + parser.parse(`classDiagram +class C1["OneWord"] +class C2["With, Comma"] +class C3["With (Brackets)"] +class C4["With [Brackets]"] +class C5["With {Brackets}"] +class C6[" "] +class C7["With 1 number"] +class C8["With . period..."] +class C9["With - dash"] +class C10["With _ underscore"] +class C11["With ' single quote"] +class C12["With ~!@#$%^&*()_+=-/?"] +class C13["With Città foreign language"] +`); + expect(classDb.getClass('C1').label).toBe('OneWord'); + expect(classDb.getClass('C2').label).toBe('With, Comma'); + expect(classDb.getClass('C3').label).toBe('With (Brackets)'); + expect(classDb.getClass('C4').label).toBe('With [Brackets]'); + expect(classDb.getClass('C5').label).toBe('With {Brackets}'); + expect(classDb.getClass('C6').label).toBe(' '); + expect(classDb.getClass('C7').label).toBe('With 1 number'); + expect(classDb.getClass('C8').label).toBe('With . period...'); + expect(classDb.getClass('C9').label).toBe('With - dash'); + expect(classDb.getClass('C10').label).toBe('With _ underscore'); + expect(classDb.getClass('C11').label).toBe("With ' single quote"); + expect(classDb.getClass('C12').label).toBe('With ~!@#$%^&*()_+=-/?'); + expect(classDb.getClass('C13').label).toBe('With Città foreign language'); + }); + }); }); diff --git a/packages/mermaid/src/diagrams/class/classRenderer-v2.js b/packages/mermaid/src/diagrams/class/classRenderer-v2.js deleted file mode 100644 index b7e538583..000000000 --- a/packages/mermaid/src/diagrams/class/classRenderer-v2.js +++ /dev/null @@ -1,499 +0,0 @@ -import { select } from 'd3'; -import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; -import { log } from '../../logger'; -import { getConfig } from '../../config'; -import { render } from '../../dagre-wrapper/index.js'; -import utils from '../../utils'; -import { curveLinear } from 'd3'; -import { interpolateToCurve, getStylesFromArray } from '../../utils'; -import { setupGraphViewbox } from '../../setupGraphViewbox'; -import common from '../common/common'; - -const sanitizeText = (txt) => common.sanitizeText(txt, getConfig()); - -let conf = { - dividerMargin: 10, - padding: 5, - textHeight: 10, -}; - -/** - * Function that adds the vertices found during parsing to the graph to be rendered. - * - * @param {Object< - * string, - * { cssClasses: string[]; text: string; id: string; type: string; domId: string } - * >} classes - * Object containing the vertices. - * @param {SVGGElement} g The graph that is to be drawn. - * @param _id - * @param diagObj - */ -export const addClasses = function (classes, g, _id, diagObj) { - // const svg = select(`[id="${svgId}"]`); - const keys = Object.keys(classes); - log.info('keys:', keys); - log.info(classes); - - // Iterate through each item in the vertex object (containing all the vertices found) in the graph definition - keys.forEach(function (id) { - const vertex = classes[id]; - - /** - * Variable for storing the classes for the vertex - * - * @type {string} - */ - let cssClassStr = ''; - if (vertex.cssClasses.length > 0) { - cssClassStr = cssClassStr + ' ' + vertex.cssClasses.join(' '); - } - // if (vertex.classes.length > 0) { - // classStr = vertex.classes.join(' '); - // } - - const styles = { labelStyle: '' }; //getStylesFromArray(vertex.styles); - - // Use vertex id as text in the box if no text is provided by the graph definition - let vertexText = vertex.text !== undefined ? vertex.text : vertex.id; - - // We create a SVG label, either by delegating to addHtmlLabel or manually - // let vertexNode; - // if (evaluate(getConfig().flowchart.htmlLabels)) { - // const node = { - // label: vertexText.replace( - // eslint-disable-next-line @cspell/spellchecker - // /fa[lrsb]?:fa-[\w-]+/g, - // s => `` - // ) - // }; - // vertexNode = addHtmlLabel(svg, node).node(); - // vertexNode.parentNode.removeChild(vertexNode); - // } else { - // const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - // svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:')); - - // const rows = vertexText.split(common.lineBreakRegex); - - // for (let j = 0; j < rows.length; j++) { - // const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); - // tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve'); - // tspan.setAttribute('dy', '1em'); - // tspan.setAttribute('x', '1'); - // tspan.textContent = rows[j]; - // svgLabel.appendChild(tspan); - // } - // vertexNode = svgLabel; - // } - - let radious = 0; - let _shape = ''; - // Set the shape based parameters - switch (vertex.type) { - case 'class': - _shape = 'class_box'; - break; - default: - _shape = 'class_box'; - } - // Add the node - g.setNode(vertex.id, { - labelStyle: styles.labelStyle, - shape: _shape, - labelText: sanitizeText(vertexText), - classData: vertex, - rx: radious, - ry: radious, - class: cssClassStr, - style: styles.style, - id: vertex.id, - domId: vertex.domId, - tooltip: diagObj.db.getTooltip(vertex.id) || '', - haveCallback: vertex.haveCallback, - link: vertex.link, - width: vertex.type === 'group' ? 500 : undefined, - type: vertex.type, - padding: getConfig().flowchart.padding, - }); - - log.info('setNode', { - labelStyle: styles.labelStyle, - shape: _shape, - labelText: vertexText, - rx: radious, - ry: radious, - class: cssClassStr, - style: styles.style, - id: vertex.id, - width: vertex.type === 'group' ? 500 : undefined, - type: vertex.type, - padding: getConfig().flowchart.padding, - }); - }); -}; - -/** - * Function that adds the additional vertices (notes) found during parsing to the graph to be rendered. - * - * @param {{text: string; class: string; placement: number}[]} notes - * Object containing the additional vertices (notes). - * @param {SVGGElement} g The graph that is to be drawn. - * @param {number} startEdgeId starting index for note edge - * @param classes - */ -export const addNotes = function (notes, g, startEdgeId, classes) { - log.info(notes); - - // Iterate through each item in the vertex object (containing all the vertices found) in the graph definition - notes.forEach(function (note, i) { - const vertex = note; - - /** - * Variable for storing the classes for the vertex - * - * @type {string} - */ - let cssNoteStr = ''; - - const styles = { labelStyle: '', style: '' }; - - // Use vertex id as text in the box if no text is provided by the graph definition - let vertexText = vertex.text; - - let radious = 0; - let _shape = 'note'; - // Add the node - g.setNode(vertex.id, { - labelStyle: styles.labelStyle, - shape: _shape, - labelText: sanitizeText(vertexText), - noteData: vertex, - rx: radious, - ry: radious, - class: cssNoteStr, - style: styles.style, - id: vertex.id, - domId: vertex.id, - tooltip: '', - type: 'note', - padding: getConfig().flowchart.padding, - }); - - log.info('setNode', { - labelStyle: styles.labelStyle, - shape: _shape, - labelText: vertexText, - rx: radious, - ry: radious, - style: styles.style, - id: vertex.id, - type: 'note', - padding: getConfig().flowchart.padding, - }); - - if (!vertex.class || !(vertex.class in classes)) { - return; - } - const edgeId = startEdgeId + i; - const edgeData = {}; - //Set relationship style and line type - edgeData.classes = 'relation'; - edgeData.pattern = 'dotted'; - - edgeData.id = `edgeNote${edgeId}`; - // Set link type for rendering - edgeData.arrowhead = 'none'; - - log.info(`Note edge: ${JSON.stringify(edgeData)}, ${JSON.stringify(vertex)}`); - //Set edge extra labels - edgeData.startLabelRight = ''; - edgeData.endLabelLeft = ''; - - //Set relation arrow types - edgeData.arrowTypeStart = 'none'; - edgeData.arrowTypeEnd = 'none'; - let style = 'fill:none'; - let labelStyle = ''; - - edgeData.style = style; - edgeData.labelStyle = labelStyle; - - edgeData.curve = interpolateToCurve(conf.curve, curveLinear); - - // Add the edge to the graph - g.setEdge(vertex.id, vertex.class, edgeData, edgeId); - }); -}; - -/** - * Add edges to graph based on parsed graph definition - * - * @param relations - * @param {object} g The graph object - */ -export const addRelations = function (relations, g) { - const conf = getConfig().flowchart; - let cnt = 0; - - let defaultStyle; - let defaultLabelStyle; - - // if (typeof relations.defaultStyle !== 'undefined') { - // const defaultStyles = getStylesFromArray(relations.defaultStyle); - // defaultStyle = defaultStyles.style; - // defaultLabelStyle = defaultStyles.labelStyle; - // } - - relations.forEach(function (edge) { - cnt++; - const edgeData = {}; - //Set relationship style and line type - edgeData.classes = 'relation'; - edgeData.pattern = edge.relation.lineType == 1 ? 'dashed' : 'solid'; - - edgeData.id = 'id' + cnt; - // Set link type for rendering - if (edge.type === 'arrow_open') { - edgeData.arrowhead = 'none'; - } else { - edgeData.arrowhead = 'normal'; - } - - log.info(edgeData, edge); - //Set edge extra labels - //edgeData.startLabelLeft = edge.relationTitle1; - edgeData.startLabelRight = edge.relationTitle1 === 'none' ? '' : edge.relationTitle1; - edgeData.endLabelLeft = edge.relationTitle2 === 'none' ? '' : edge.relationTitle2; - //edgeData.endLabelRight = edge.relationTitle2; - - //Set relation arrow types - edgeData.arrowTypeStart = getArrowMarker(edge.relation.type1); - edgeData.arrowTypeEnd = getArrowMarker(edge.relation.type2); - let style = ''; - let labelStyle = ''; - - if (edge.style !== undefined) { - const styles = getStylesFromArray(edge.style); - style = styles.style; - labelStyle = styles.labelStyle; - } else { - style = 'fill:none'; - if (defaultStyle !== undefined) { - style = defaultStyle; - } - if (defaultLabelStyle !== undefined) { - labelStyle = defaultLabelStyle; - } - } - - edgeData.style = style; - edgeData.labelStyle = labelStyle; - - if (edge.interpolate !== undefined) { - edgeData.curve = interpolateToCurve(edge.interpolate, curveLinear); - } else if (relations.defaultInterpolate !== undefined) { - edgeData.curve = interpolateToCurve(relations.defaultInterpolate, curveLinear); - } else { - edgeData.curve = interpolateToCurve(conf.curve, curveLinear); - } - - edge.text = edge.title; - if (edge.text === undefined) { - if (edge.style !== undefined) { - edgeData.arrowheadStyle = 'fill: #333'; - } - } else { - edgeData.arrowheadStyle = 'fill: #333'; - edgeData.labelpos = 'c'; - - if (getConfig().flowchart.htmlLabels) { - edgeData.labelType = 'html'; - edgeData.label = '' + edge.text + ''; - } else { - edgeData.labelType = 'text'; - edgeData.label = edge.text.replace(common.lineBreakRegex, '\n'); - - if (edge.style === undefined) { - edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none'; - } - - edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:'); - } - } - // Add the edge to the graph - g.setEdge(edge.id1, edge.id2, edgeData, cnt); - }); -}; - -/** - * Merges the value of `conf` with the passed `cnf` - * - * @param {object} cnf Config to merge - */ -export const setConf = function (cnf) { - const keys = Object.keys(cnf); - - keys.forEach(function (key) { - conf[key] = cnf[key]; - }); -}; - -/** - * Draws a flowchart in the tag with id: id based on the graph definition in text. - * - * @param {string} text - * @param {string} id - * @param _version - * @param diagObj - */ -export const draw = function (text, id, _version, diagObj) { - log.info('Drawing class - ', id); - - const conf = getConfig().flowchart; - const securityLevel = getConfig().securityLevel; - log.info('config:', conf); - const nodeSpacing = conf.nodeSpacing || 50; - const rankSpacing = conf.rankSpacing || 50; - - // Create the input mermaid.graph - const g = new graphlib.Graph({ - multigraph: true, - compound: true, - }) - .setGraph({ - rankdir: diagObj.db.getDirection(), - nodesep: nodeSpacing, - ranksep: rankSpacing, - marginx: 8, - marginy: 8, - }) - .setDefaultEdgeLabel(function () { - return {}; - }); - - // Fetch the vertices/nodes and edges/links from the parsed graph definition - const classes = diagObj.db.getClasses(); - const relations = diagObj.db.getRelations(); - const notes = diagObj.db.getNotes(); - - log.info(relations); - addClasses(classes, g, id, diagObj); - addRelations(relations, g); - addNotes(notes, g, relations.length + 1, classes); - - // Add custom shapes - // flowChartShapes.addToRenderV2(addShape); - - // Set up an SVG group so that we can translate the final graph. - let sandboxElement; - if (securityLevel === 'sandbox') { - sandboxElement = select('#i' + id); - } - const root = - securityLevel === 'sandbox' - ? select(sandboxElement.nodes()[0].contentDocument.body) - : select('body'); - const svg = root.select(`[id="${id}"]`); - - // Run the renderer. This is what draws the final graph. - const element = root.select('#' + id + ' g'); - render( - element, - g, - ['aggregation', 'extension', 'composition', 'dependency', 'lollipop'], - 'classDiagram', - id - ); - - utils.insertTitle(svg, 'classTitleText', conf.titleTopMargin, diagObj.db.getDiagramTitle()); - - setupGraphViewbox(g, svg, conf.diagramPadding, conf.useMaxWidth); - - // Add label rects for non html labels - if (!conf.htmlLabels) { - const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document; - const labels = doc.querySelectorAll('[id="' + id + '"] .edgeLabel .label'); - for (const label of labels) { - // Get dimensions of label - const dim = label.getBBox(); - - const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect'); - rect.setAttribute('rx', 0); - rect.setAttribute('ry', 0); - rect.setAttribute('width', dim.width); - rect.setAttribute('height', dim.height); - // rect.setAttribute('style', 'fill:#e8e8e8;'); - - label.insertBefore(rect, label.firstChild); - } - } - - // If node has a link, wrap it in an anchor SVG object. - // const keys = Object.keys(classes); - // keys.forEach(function(key) { - // const vertex = classes[key]; - - // if (vertex.link) { - // const node = select('#' + id + ' [id="' + key + '"]'); - // if (node) { - // const link = document.createElementNS('http://www.w3.org/2000/svg', 'a'); - // link.setAttributeNS('http://www.w3.org/2000/svg', 'class', vertex.classes.join(' ')); - // link.setAttributeNS('http://www.w3.org/2000/svg', 'href', vertex.link); - // link.setAttributeNS('http://www.w3.org/2000/svg', 'rel', 'noopener'); - - // const linkNode = node.insert(function() { - // return link; - // }, ':first-child'); - - // const shape = node.select('.label-container'); - // if (shape) { - // linkNode.append(function() { - // return shape.node(); - // }); - // } - - // const label = node.select('.label'); - // if (label) { - // linkNode.append(function() { - // return label.node(); - // }); - // } - // } - // } - // }); -}; - -/** - * Gets the arrow marker for a type index - * - * @param {number} type The type to look for - * @returns {'aggregation' | 'extension' | 'composition' | 'dependency'} The arrow marker - */ -function getArrowMarker(type) { - 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; -} - -export default { - setConf, - draw, -}; diff --git a/packages/mermaid/src/diagrams/class/classRenderer-v2.ts b/packages/mermaid/src/diagrams/class/classRenderer-v2.ts new file mode 100644 index 000000000..e308990c6 --- /dev/null +++ b/packages/mermaid/src/diagrams/class/classRenderer-v2.ts @@ -0,0 +1,368 @@ +// @ts-ignore d3 types are not available +import { select, curveLinear } from 'd3'; +import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; +import { log } from '../../logger'; +import { getConfig } from '../../config'; +import { render } from '../../dagre-wrapper/index.js'; +import utils from '../../utils'; +import { interpolateToCurve, getStylesFromArray } from '../../utils'; +import { setupGraphViewbox } from '../../setupGraphViewbox'; +import common from '../common/common'; +import { ClassRelation, ClassNote, ClassMap, EdgeData } from './classTypes'; + +const sanitizeText = (txt: string) => common.sanitizeText(txt, getConfig()); + +let conf = { + dividerMargin: 10, + padding: 5, + textHeight: 10, + curve: undefined, +}; + +/** + * Function that adds the vertices found during parsing to the graph to be rendered. + * + * @param classes - Object containing the vertices. + * @param g - The graph that is to be drawn. + * @param _id - id of the graph + * @param diagObj - The diagram object + */ +export const addClasses = function ( + classes: ClassMap, + g: graphlib.Graph, + _id: string, + diagObj: any +) { + const keys = Object.keys(classes); + log.info('keys:', keys); + log.info(classes); + + // Iterate through each item in the vertex object (containing all the vertices found) in the graph definition + keys.forEach(function (id) { + const vertex = classes[id]; + + /** + * Variable for storing the classes for the vertex + */ + let cssClassStr = ''; + if (vertex.cssClasses.length > 0) { + cssClassStr = cssClassStr + ' ' + vertex.cssClasses.join(' '); + } + + const styles = { labelStyle: '', style: '' }; //getStylesFromArray(vertex.styles); + + // Use vertex id as text in the box if no text is provided by the graph definition + const vertexText = vertex.label ?? vertex.id; + const radius = 0; + const shape = 'class_box'; + // Add the node + const node = { + labelStyle: styles.labelStyle, + shape: shape, + labelText: sanitizeText(vertexText), + classData: vertex, + rx: radius, + ry: radius, + class: cssClassStr, + style: styles.style, + id: vertex.id, + domId: vertex.domId, + tooltip: diagObj.db.getTooltip(vertex.id) || '', + haveCallback: vertex.haveCallback, + link: vertex.link, + width: vertex.type === 'group' ? 500 : undefined, + type: vertex.type, + // TODO V10: Flowchart ? Keeping flowchart for backwards compatibility. Remove in next major release + padding: getConfig().flowchart?.padding ?? getConfig().class?.padding, + }; + g.setNode(vertex.id, node); + log.info('setNode', node); + }); +}; + +/** + * Function that adds the additional vertices (notes) found during parsing to the graph to be rendered. + * + * @param notes - Object containing the additional vertices (notes). + * @param g - The graph that is to be drawn. + * @param startEdgeId - starting index for note edge + * @param classes - Classes + */ +export const addNotes = function ( + notes: ClassNote[], + g: graphlib.Graph, + startEdgeId: number, + classes: ClassMap +) { + log.info(notes); + + // Iterate through each item in the vertex object (containing all the vertices found) in the graph definition + notes.forEach(function (note, i) { + const vertex = note; + + /** + * Variable for storing the classes for the vertex + * + */ + const cssNoteStr = ''; + + const styles = { labelStyle: '', style: '' }; + + // Use vertex id as text in the box if no text is provided by the graph definition + const vertexText = vertex.text; + + const radius = 0; + const shape = 'note'; + // Add the node + const node = { + labelStyle: styles.labelStyle, + shape: shape, + labelText: sanitizeText(vertexText), + noteData: vertex, + rx: radius, + ry: radius, + class: cssNoteStr, + style: styles.style, + id: vertex.id, + domId: vertex.id, + tooltip: '', + type: 'note', + // TODO V10: Flowchart ? Keeping flowchart for backwards compatibility. Remove in next major release + padding: getConfig().flowchart?.padding ?? getConfig().class?.padding, + }; + g.setNode(vertex.id, node); + log.info('setNode', node); + + if (!vertex.class || !(vertex.class in classes)) { + return; + } + const edgeId = startEdgeId + i; + + const edgeData: EdgeData = { + id: `edgeNote${edgeId}`, + //Set relationship style and line type + classes: 'relation', + pattern: 'dotted', + // Set link type for rendering + arrowhead: 'none', + //Set edge extra labels + startLabelRight: '', + endLabelLeft: '', + //Set relation arrow types + arrowTypeStart: 'none', + arrowTypeEnd: 'none', + style: 'fill:none', + labelStyle: '', + curve: interpolateToCurve(conf.curve, curveLinear), + }; + + // Add the edge to the graph + g.setEdge(vertex.id, vertex.class, edgeData, edgeId); + }); +}; + +/** + * Add edges to graph based on parsed graph definition + * + * @param relations - + * @param g - The graph object + */ +export const addRelations = function (relations: ClassRelation[], g: graphlib.Graph) { + const conf = getConfig().flowchart; + let cnt = 0; + + relations.forEach(function (edge) { + cnt++; + const edgeData: EdgeData = { + //Set relationship style and line type + classes: 'relation', + pattern: edge.relation.lineType == 1 ? 'dashed' : 'solid', + id: 'id' + cnt, + // Set link type for rendering + arrowhead: edge.type === 'arrow_open' ? 'none' : 'normal', + //Set edge extra labels + startLabelRight: edge.relationTitle1 === 'none' ? '' : edge.relationTitle1, + endLabelLeft: edge.relationTitle2 === 'none' ? '' : edge.relationTitle2, + //Set relation arrow types + arrowTypeStart: getArrowMarker(edge.relation.type1), + arrowTypeEnd: getArrowMarker(edge.relation.type2), + style: 'fill:none', + labelStyle: '', + curve: interpolateToCurve(conf?.curve, curveLinear), + }; + + log.info(edgeData, edge); + + if (edge.style !== undefined) { + const styles = getStylesFromArray(edge.style); + edgeData.style = styles.style; + edgeData.labelStyle = styles.labelStyle; + } + + edge.text = edge.title; + if (edge.text === undefined) { + if (edge.style !== undefined) { + edgeData.arrowheadStyle = 'fill: #333'; + } + } else { + edgeData.arrowheadStyle = 'fill: #333'; + edgeData.labelpos = 'c'; + + // TODO V10: Flowchart ? Keeping flowchart for backwards compatibility. Remove in next major release + if (getConfig().flowchart?.htmlLabels ?? getConfig().htmlLabels) { + edgeData.labelType = 'html'; + edgeData.label = '' + edge.text + ''; + } else { + edgeData.labelType = 'text'; + edgeData.label = edge.text.replace(common.lineBreakRegex, '\n'); + + if (edge.style === undefined) { + edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none'; + } + + edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:'); + } + } + // Add the edge to the graph + g.setEdge(edge.id1, edge.id2, edgeData, cnt); + }); +}; + +/** + * Merges the value of `conf` with the passed `cnf` + * + * @param cnf - Config to merge + */ +export const setConf = function (cnf: any) { + conf = { + ...conf, + ...cnf, + }; +}; + +/** + * Draws a flowchart in the tag with id: id based on the graph definition in text. + * + * @param text - + * @param id - + * @param _version - + * @param diagObj - + */ +export const draw = function (text: string, id: string, _version: string, diagObj: any) { + log.info('Drawing class - ', id); + + // TODO V10: Why flowchart? Might be a mistake when copying. + const conf = getConfig().flowchart ?? getConfig().class; + const securityLevel = getConfig().securityLevel; + log.info('config:', conf); + const nodeSpacing = conf?.nodeSpacing ?? 50; + const rankSpacing = conf?.rankSpacing ?? 50; + + // Create the input mermaid.graph + const g: graphlib.Graph = new graphlib.Graph({ + multigraph: true, + compound: true, + }) + .setGraph({ + rankdir: diagObj.db.getDirection(), + nodesep: nodeSpacing, + ranksep: rankSpacing, + marginx: 8, + marginy: 8, + }) + .setDefaultEdgeLabel(function () { + return {}; + }); + + // Fetch the vertices/nodes and edges/links from the parsed graph definition + const classes: ClassMap = diagObj.db.getClasses(); + const relations: ClassRelation[] = diagObj.db.getRelations(); + const notes: ClassNote[] = diagObj.db.getNotes(); + log.info(relations); + addClasses(classes, g, id, diagObj); + addRelations(relations, g); + addNotes(notes, g, relations.length + 1, classes); + + // Set up an SVG group so that we can translate the final graph. + let sandboxElement; + if (securityLevel === 'sandbox') { + sandboxElement = select('#i' + id); + } + const root = + securityLevel === 'sandbox' + ? // @ts-ignore Ignore type error for now + + select(sandboxElement.nodes()[0].contentDocument.body) + : select('body'); + // @ts-ignore Ignore type error for now + const svg = root.select(`[id="${id}"]`); + + // Run the renderer. This is what draws the final graph. + // @ts-ignore Ignore type error for now + const element = root.select('#' + id + ' g'); + render( + element, + g, + ['aggregation', 'extension', 'composition', 'dependency', 'lollipop'], + 'classDiagram', + id + ); + + utils.insertTitle(svg, 'classTitleText', conf?.titleTopMargin ?? 5, diagObj.db.getDiagramTitle()); + + setupGraphViewbox(g, svg, conf?.diagramPadding, conf?.useMaxWidth); + + // Add label rects for non html labels + if (!conf?.htmlLabels) { + // @ts-ignore Ignore type error for now + const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document; + const labels = doc.querySelectorAll('[id="' + id + '"] .edgeLabel .label'); + for (const label of labels) { + // Get dimensions of label + const dim = label.getBBox(); + + const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('rx', 0); + rect.setAttribute('ry', 0); + rect.setAttribute('width', dim.width); + rect.setAttribute('height', dim.height); + + label.insertBefore(rect, label.firstChild); + } + } +}; + +/** + * 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; +} + +export default { + setConf, + draw, +}; diff --git a/packages/mermaid/src/diagrams/class/classTypes.ts b/packages/mermaid/src/diagrams/class/classTypes.ts new file mode 100644 index 000000000..4cacad3db --- /dev/null +++ b/packages/mermaid/src/diagrams/class/classTypes.ts @@ -0,0 +1,55 @@ +export interface ClassNode { + id: string; + type: string; + label: string; + cssClasses: string[]; + methods: string[]; + members: string[]; + annotations: string[]; + domId: string; + link?: string; + linkTarget?: string; + haveCallback?: boolean; + tooltip?: string; +} + +export interface ClassNote { + id: string; + class: string; + text: string; +} + +export interface EdgeData { + arrowheadStyle?: string; + labelpos?: string; + labelType?: string; + label?: string; + classes: string; + pattern: string; + id: string; + arrowhead: string; + startLabelRight: string; + endLabelLeft: string; + arrowTypeStart: string; + arrowTypeEnd: string; + style: string; + labelStyle: string; + curve: any; +} + +export type ClassRelation = { + id1: string; + id2: string; + relationTitle1: string; + relationTitle2: string; + type: string; + title: string; + text: string; + style: string[]; + relation: { + type1: number; + type2: number; + lineType: number; + }; +}; +export type ClassMap = Record; diff --git a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison index 157e3d7d8..e98b0253b 100644 --- a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison +++ b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison @@ -119,6 +119,8 @@ Function arguments are optional: 'call ()' simply executes 'callb "=" return 'EQUALS'; \= return 'EQUALS'; \w+ return 'ALPHA'; +"[" return 'SQS'; +"]" return 'SQE'; [!"#$%&'*+,-.`?\\/] return 'PUNCTUATION'; [0-9]+ return 'NUM'; [\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]| @@ -249,6 +251,10 @@ statements | statement NEWLINE statements ; +classLabel + : SQS STR SQE { $$=$2; } + ; + className : alphaNumToken { $$=$1; } | classLiteralName { $$=$1; } @@ -274,10 +280,15 @@ statement ; classStatement - : CLASS className {yy.addClass($2);} - | CLASS className STYLE_SEPARATOR alphaNumToken {yy.addClass($2);yy.setCssClass($2, $4);} - | CLASS className STRUCT_START members STRUCT_STOP {/*console.log($2,JSON.stringify($4));*/yy.addClass($2);yy.addMembers($2,$4);} - | CLASS className STYLE_SEPARATOR alphaNumToken STRUCT_START members STRUCT_STOP {yy.addClass($2);yy.setCssClass($2, $4);yy.addMembers($2,$6);} + : classIdentifier + | classIdentifier STYLE_SEPARATOR alphaNumToken {yy.setCssClass($1, $3);} + | classIdentifier STRUCT_START members STRUCT_STOP {yy.addMembers($1,$3);} + | classIdentifier STYLE_SEPARATOR alphaNumToken STRUCT_START members STRUCT_STOP {yy.setCssClass($1, $3);yy.addMembers($1,$3);} + ; + +classIdentifier + : CLASS className {$$=$2; yy.addClass($2);} + | CLASS className classLabel {$$=$2; yy.addClass($2);yy.setClassLabel($2, $3);} ; annotationStatement diff --git a/packages/mermaid/src/diagrams/er/parser/erDiagram.jison b/packages/mermaid/src/diagrams/er/parser/erDiagram.jison index 6aaca81d6..d9f03c387 100644 --- a/packages/mermaid/src/diagrams/er/parser/erDiagram.jison +++ b/packages/mermaid/src/diagrams/er/parser/erDiagram.jison @@ -35,6 +35,8 @@ accDescr\s*"{"\s* { this.begin("acc_descr_multili [A-Za-z_][A-Za-z0-9\-_\[\]\(\)]* return 'ATTRIBUTE_WORD' \"[^"]*\" return 'COMMENT'; [\n]+ /* nothing */ +\%%(?!\{)[^\n]* /* skip comments in attribute block */ +[^\}]\%\%[^\n]* /* skip comments in attribute block */ "}" { this.popState(); return 'BLOCK_STOP'; } . return yytext[0]; diff --git a/packages/mermaid/src/diagrams/flowchart/flowDetector-v2.ts b/packages/mermaid/src/diagrams/flowchart/flowDetector-v2.ts index 6162cc828..5b2aaf1bd 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDetector-v2.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDetector-v2.ts @@ -4,15 +4,15 @@ import type { ExternalDiagramDefinition } from '../../diagram-api/types'; const id = 'flowchart-v2'; const detector: DiagramDetector = (txt, config) => { - if (config?.flowchart?.defaultRenderer === 'dagre-d3') { - return false; - } - if (config?.flowchart?.defaultRenderer === 'elk') { + if ( + config?.flowchart?.defaultRenderer === 'dagre-d3' || + config?.flowchart?.defaultRenderer === 'elk' + ) { return false; } // If we have configured to use dagre-wrapper then we should return true in this function for graph code thus making it use the new flowchart diagram - if (txt.match(/^\s*graph/) !== null) { + if (txt.match(/^\s*graph/) !== null && config?.flowchart?.defaultRenderer === 'dagre-wrapper') { return true; } return txt.match(/^\s*flowchart/) !== null; diff --git a/packages/mermaid/src/diagrams/flowchart/flowDetector.ts b/packages/mermaid/src/diagrams/flowchart/flowDetector.ts index 9457ff469..a8b116ccd 100644 --- a/packages/mermaid/src/diagrams/flowchart/flowDetector.ts +++ b/packages/mermaid/src/diagrams/flowchart/flowDetector.ts @@ -5,10 +5,10 @@ const id = 'flowchart'; const detector: DiagramDetector = (txt, config) => { // If we have conferred to only use new flow charts this function should always return false // as in not signalling true for a legacy flowchart - if (config?.flowchart?.defaultRenderer === 'dagre-wrapper') { - return false; - } - if (config?.flowchart?.defaultRenderer === 'elk') { + if ( + config?.flowchart?.defaultRenderer === 'dagre-wrapper' || + config?.flowchart?.defaultRenderer === 'elk' + ) { return false; } return txt.match(/^\s*graph/) !== null; diff --git a/packages/mermaid/src/diagrams/gantt/ganttDb.js b/packages/mermaid/src/diagrams/gantt/ganttDb.js index a1c74dd62..27ad1a0b8 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttDb.js +++ b/packages/mermaid/src/diagrams/gantt/ganttDb.js @@ -1,5 +1,8 @@ -import moment from 'moment-mini'; import { sanitizeUrl } from '@braintree/sanitize-url'; +import dayjs from 'dayjs'; +import dayjsIsoWeek from 'dayjs/plugin/isoWeek'; +import dayjsCustomParseFormat from 'dayjs/plugin/customParseFormat'; +import dayjsAdvancedFormat from 'dayjs/plugin/advancedFormat'; import { log } from '../../logger'; import * as configApi from '../../config'; import utils from '../../utils'; @@ -15,6 +18,10 @@ import { getDiagramTitle, } from '../../commonDb'; +dayjs.extend(dayjsIsoWeek); +dayjs.extend(dayjsCustomParseFormat); +dayjs.extend(dayjsAdvancedFormat); + let dateFormat = ''; let axisFormat = ''; let tickInterval = undefined; @@ -162,18 +169,58 @@ export const isInvalidDate = function (date, dateFormat, excludes, includes) { return excludes.includes(date.format(dateFormat.trim())); }; +/** + * TODO: fully document what this function does and what types it accepts + * + * @param {object} task - The task to check. + * @param {string | Date} task.startTime - Might be a `Date` or a `string`. + * TODO: is this always a Date? + * @param {string | Date} task.endTime - Might be a `Date` or a `string`. + * TODO: is this always a Date? + * @param {string} dateFormat - Dayjs date format string. + * @param {*} excludes + * @param {*} includes + */ const checkTaskDates = function (task, dateFormat, excludes, includes) { if (!excludes.length || task.manualEndTime) { return; } - let startTime = moment(task.startTime, dateFormat, true); - startTime.add(1, 'd'); - let endTime = moment(task.endTime, dateFormat, true); - let renderEndTime = fixTaskDates(startTime, endTime, dateFormat, excludes, includes); - task.endTime = endTime.toDate(); + let startTime; + if (task.startTime instanceof Date) { + startTime = dayjs(task.startTime); + } else { + startTime = dayjs(task.startTime, dateFormat, true); + } + startTime = startTime.add(1, 'd'); + + let originalEndTime; + if (task.endTime instanceof Date) { + originalEndTime = dayjs(task.endTime); + } else { + originalEndTime = dayjs(task.endTime, dateFormat, true); + } + const [fixedEndTime, renderEndTime] = fixTaskDates( + startTime, + originalEndTime, + dateFormat, + excludes, + includes + ); + task.endTime = fixedEndTime.toDate(); task.renderEndTime = renderEndTime; }; +/** + * TODO: what does this function do? + * + * @param {dayjs.Dayjs} startTime - The start time. + * @param {dayjs.Dayjs} endTime - The original end time (will return a different end time if it's invalid). + * @param {string} dateFormat - Dayjs date format string. + * @param {*} excludes + * @param {*} includes + * @returns {[endTime: dayjs.Dayjs, renderEndTime: Date | null]} The new `endTime`, and the end time to render. + * `renderEndTime` may be `null` if `startTime` is newer than `endTime`. + */ const fixTaskDates = function (startTime, endTime, dateFormat, excludes, includes) { let invalid = false; let renderEndTime = null; @@ -183,11 +230,11 @@ const fixTaskDates = function (startTime, endTime, dateFormat, excludes, include } invalid = isInvalidDate(startTime, dateFormat, excludes, includes); if (invalid) { - endTime.add(1, 'd'); + endTime = endTime.add(1, 'd'); } - startTime.add(1, 'd'); + startTime = startTime.add(1, 'd'); } - return renderEndTime; + return [endTime, renderEndTime]; }; const getStartDate = function (prevTime, dateFormat, str) { @@ -223,7 +270,7 @@ const getStartDate = function (prevTime, dateFormat, str) { } // Check for actual date set - let mDate = moment(str, dateFormat.trim(), true); + let mDate = dayjs(str, dateFormat.trim(), true); if (mDate.isValid()) { return mDate.toDate(); } else { @@ -238,11 +285,14 @@ const getStartDate = function (prevTime, dateFormat, str) { }; /** - * Parse a string as a moment duration. + * Parse a string into the args for `dayjs.add()`. * * The string have to be compound by a value and a shorthand duration unit. For example `5d` * represents 5 days. * + * Please be aware that 1 day may be 23 or 25 hours, if the user lives in an area + * that has daylight savings time (or even 23.5/24.5 hours in Lord Howe Island!) + * * Shorthand unit supported are: * * - `y` for years @@ -254,33 +304,36 @@ const getStartDate = function (prevTime, dateFormat, str) { * - `ms` for milliseconds * * @param {string} str - A string representing the duration. - * @returns {moment.Duration} A moment duration, including an invalid moment for invalid input - * string. + * @returns {[value: number, unit: dayjs.ManipulateType]} Arguments to pass to `dayjs.add()` */ const parseDuration = function (str) { const statement = /^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$/.exec(str.trim()); if (statement !== null) { - return moment.duration(Number.parseFloat(statement[1]), statement[2]); + return [Number.parseFloat(statement[1]), statement[2]]; } - return moment.duration.invalid(); + // NaN means an invalid duration + return [NaN, 'ms']; }; const getEndDate = function (prevTime, dateFormat, str, inclusive = false) { str = str.trim(); // Check for actual date - let mDate = moment(str, dateFormat.trim(), true); + let mDate = dayjs(str, dateFormat.trim(), true); if (mDate.isValid()) { if (inclusive) { - mDate.add(1, 'd'); + mDate = mDate.add(1, 'd'); } return mDate.toDate(); } - const endTime = moment(prevTime); - const duration = parseDuration(str); - if (duration.isValid()) { - endTime.add(duration); + let endTime = dayjs(prevTime); + const [durationValue, durationUnit] = parseDuration(str); + if (!Number.isNaN(durationValue)) { + const newEndTime = endTime.add(durationValue, durationUnit); + if (newEndTime.isValid()) { + endTime = newEndTime; + } } return endTime.toDate(); }; @@ -346,7 +399,7 @@ const compileData = function (prevTask, dataStr) { if (endTimeData) { task.endTime = getEndDate(task.startTime, dateFormat, endTimeData, inclusiveEndDates); - task.manualEndTime = moment(endTimeData, 'YYYY-MM-DD', true).isValid(); + task.manualEndTime = dayjs(endTimeData, 'YYYY-MM-DD', true).isValid(); checkTaskDates(task, dateFormat, excludes, includes); } @@ -496,7 +549,7 @@ const compileTasks = function () { ); if (rawTasks[pos].endTime) { rawTasks[pos].processed = true; - rawTasks[pos].manualEndTime = moment( + rawTasks[pos].manualEndTime = dayjs( rawTasks[pos].raw.endTime.data, 'YYYY-MM-DD', true diff --git a/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts b/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts index 09df96f12..d65f2fdfd 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts +++ b/packages/mermaid/src/diagrams/gantt/ganttDb.spec.ts @@ -1,5 +1,5 @@ // @ts-nocheck TODO: Fix TS -import moment from 'moment-mini'; +import dayjs from 'dayjs'; import ganttDb from './ganttDb'; import { convert } from '../../tests/util'; @@ -9,7 +9,7 @@ describe('when using the ganttDb', function () { }); describe('when using duration', function () { - it.each([{ str: '1d', expected: moment.duration(1, 'd') }])( + it.each([{ str: '1d', expected: [1, 'd'] }])( 'should %s resulting in $o duration', ({ str, expected }) => { expect(ganttDb.parseDuration(str)).toEqual(expected); @@ -19,11 +19,11 @@ describe('when using the ganttDb', function () { it.each( convert` str | expected - ${'1d'} | ${moment.duration(1, 'd')} - ${'2w'} | ${moment.duration(2, 'w')} - ${'1ms'} | ${moment.duration(1, 'ms')} - ${'0.1s'} | ${moment.duration(100, 'ms')} - ${'1f'} | ${moment.duration.invalid()} + ${'1d'} | ${[1, 'd']} + ${'2w'} | ${[2, 'w']} + ${'1ms'} | ${[1, 'ms']} + ${'0.1s'} | ${[0.1, 's']} + ${'1f'} | ${[NaN, 'ms']} ` )('should $str resulting in $expected duration', ({ str, expected }) => { expect(ganttDb.parseDuration(str)).toEqual(expected); @@ -171,44 +171,44 @@ describe('when using the ganttDb', function () { const tasks = ganttDb.getTasks(); - expect(tasks[0].startTime).toEqual(moment('2019-02-01', 'YYYY-MM-DD').toDate()); - expect(tasks[0].endTime).toEqual(moment('2019-02-04', 'YYYY-MM-DD').toDate()); - expect(tasks[0].renderEndTime).toEqual(moment('2019-02-02', 'YYYY-MM-DD').toDate()); + expect(tasks[0].startTime).toEqual(dayjs('2019-02-01', 'YYYY-MM-DD').toDate()); + expect(tasks[0].endTime).toEqual(dayjs('2019-02-04', 'YYYY-MM-DD').toDate()); + expect(tasks[0].renderEndTime).toEqual(dayjs('2019-02-02', 'YYYY-MM-DD').toDate()); expect(tasks[0].id).toEqual('id1'); expect(tasks[0].task).toEqual('test1'); - expect(tasks[1].startTime).toEqual(moment('2019-02-04', 'YYYY-MM-DD').toDate()); - expect(tasks[1].endTime).toEqual(moment('2019-02-07', 'YYYY-MM-DD').toDate()); - expect(tasks[1].renderEndTime).toEqual(moment('2019-02-06', 'YYYY-MM-DD').toDate()); + expect(tasks[1].startTime).toEqual(dayjs('2019-02-04', 'YYYY-MM-DD').toDate()); + expect(tasks[1].endTime).toEqual(dayjs('2019-02-07', 'YYYY-MM-DD').toDate()); + expect(tasks[1].renderEndTime).toEqual(dayjs('2019-02-06', 'YYYY-MM-DD').toDate()); expect(tasks[1].id).toEqual('id2'); expect(tasks[1].task).toEqual('test2'); - expect(tasks[2].startTime).toEqual(moment('2019-02-07', 'YYYY-MM-DD').toDate()); - expect(tasks[2].endTime).toEqual(moment('2019-02-20', 'YYYY-MM-DD').toDate()); - expect(tasks[2].renderEndTime).toEqual(moment('2019-02-20', 'YYYY-MM-DD').toDate()); + expect(tasks[2].startTime).toEqual(dayjs('2019-02-07', 'YYYY-MM-DD').toDate()); + expect(tasks[2].endTime).toEqual(dayjs('2019-02-20', 'YYYY-MM-DD').toDate()); + expect(tasks[2].renderEndTime).toEqual(dayjs('2019-02-20', 'YYYY-MM-DD').toDate()); expect(tasks[2].id).toEqual('id3'); expect(tasks[2].task).toEqual('test3'); - expect(tasks[3].startTime).toEqual(moment('2019-02-01', 'YYYY-MM-DD').toDate()); - expect(tasks[3].endTime).toEqual(moment('2019-02-20', 'YYYY-MM-DD').toDate()); + expect(tasks[3].startTime).toEqual(dayjs('2019-02-01', 'YYYY-MM-DD').toDate()); + expect(tasks[3].endTime).toEqual(dayjs('2019-02-20', 'YYYY-MM-DD').toDate()); expect(tasks[3].renderEndTime).toBeNull(); // Fixed end expect(tasks[3].id).toEqual('id4'); expect(tasks[3].task).toEqual('test4'); - expect(tasks[4].startTime).toEqual(moment('2019-02-20', 'YYYY-MM-DD').toDate()); - expect(tasks[4].endTime).toEqual(moment('2019-02-21', 'YYYY-MM-DD').toDate()); - expect(tasks[4].renderEndTime).toEqual(moment('2019-02-21', 'YYYY-MM-DD').toDate()); + expect(tasks[4].startTime).toEqual(dayjs('2019-02-20', 'YYYY-MM-DD').toDate()); + expect(tasks[4].endTime).toEqual(dayjs('2019-02-21', 'YYYY-MM-DD').toDate()); + expect(tasks[4].renderEndTime).toEqual(dayjs('2019-02-21', 'YYYY-MM-DD').toDate()); expect(tasks[4].id).toEqual('id5'); expect(tasks[4].task).toEqual('test5'); - expect(tasks[5].startTime).toEqual(moment('2019-02-13', 'YYYY-MM-DD').toDate()); - expect(tasks[5].endTime).toEqual(moment('2019-02-18', 'YYYY-MM-DD').toDate()); - expect(tasks[5].renderEndTime).toEqual(moment('2019-02-15', 'YYYY-MM-DD').toDate()); + expect(tasks[5].startTime).toEqual(dayjs('2019-02-13', 'YYYY-MM-DD').toDate()); + expect(tasks[5].endTime).toEqual(dayjs('2019-02-18', 'YYYY-MM-DD').toDate()); + expect(tasks[5].renderEndTime).toEqual(dayjs('2019-02-15', 'YYYY-MM-DD').toDate()); expect(tasks[5].id).toEqual('id6'); expect(tasks[5].task).toEqual('test6'); - expect(tasks[6].startTime).toEqual(moment('2019-02-18', 'YYYY-MM-DD').toDate()); - expect(tasks[6].endTime).toEqual(moment('2019-02-19', 'YYYY-MM-DD').toDate()); + expect(tasks[6].startTime).toEqual(dayjs('2019-02-18', 'YYYY-MM-DD').toDate()); + expect(tasks[6].endTime).toEqual(dayjs('2019-02-19', 'YYYY-MM-DD').toDate()); expect(tasks[6].id).toEqual('id7'); expect(tasks[6].task).toEqual('test7'); }); @@ -243,109 +243,103 @@ describe('when using the ganttDb', function () { const tasks = ganttDb.getTasks(); // Section - A section - expect(tasks[0].startTime).toEqual(moment('2014-01-06', 'YYYY-MM-DD').toDate()); - expect(tasks[0].endTime).toEqual(moment('2014-01-08', 'YYYY-MM-DD').toDate()); + expect(tasks[0].startTime).toEqual(dayjs('2014-01-06', 'YYYY-MM-DD').toDate()); + expect(tasks[0].endTime).toEqual(dayjs('2014-01-08', 'YYYY-MM-DD').toDate()); expect(tasks[0].order).toEqual(0); expect(tasks[0].id).toEqual('des1'); expect(tasks[0].task).toEqual('Completed task'); - expect(tasks[1].startTime).toEqual(moment('2014-01-09', 'YYYY-MM-DD').toDate()); - expect(tasks[1].endTime).toEqual(moment('2014-01-12', 'YYYY-MM-DD').toDate()); + expect(tasks[1].startTime).toEqual(dayjs('2014-01-09', 'YYYY-MM-DD').toDate()); + expect(tasks[1].endTime).toEqual(dayjs('2014-01-12', 'YYYY-MM-DD').toDate()); expect(tasks[1].order).toEqual(1); expect(tasks[1].id).toEqual('des2'); expect(tasks[1].task).toEqual('Active task'); - expect(tasks[2].startTime).toEqual(moment('2014-01-12', 'YYYY-MM-DD').toDate()); - expect(tasks[2].endTime).toEqual(moment('2014-01-17', 'YYYY-MM-DD').toDate()); + expect(tasks[2].startTime).toEqual(dayjs('2014-01-12', 'YYYY-MM-DD').toDate()); + expect(tasks[2].endTime).toEqual(dayjs('2014-01-17', 'YYYY-MM-DD').toDate()); expect(tasks[2].order).toEqual(2); expect(tasks[2].id).toEqual('des3'); expect(tasks[2].task).toEqual('Future task'); - expect(tasks[3].startTime).toEqual(moment('2014-01-17', 'YYYY-MM-DD').toDate()); - expect(tasks[3].endTime).toEqual(moment('2014-01-22', 'YYYY-MM-DD').toDate()); + expect(tasks[3].startTime).toEqual(dayjs('2014-01-17', 'YYYY-MM-DD').toDate()); + expect(tasks[3].endTime).toEqual(dayjs('2014-01-22', 'YYYY-MM-DD').toDate()); expect(tasks[3].order).toEqual(3); expect(tasks[3].id).toEqual('des4'); expect(tasks[3].task).toEqual('Future task2'); // Section - Critical tasks - expect(tasks[4].startTime).toEqual(moment('2014-01-06', 'YYYY-MM-DD').toDate()); - expect(tasks[4].endTime).toEqual(moment('2014-01-07', 'YYYY-MM-DD').toDate()); + expect(tasks[4].startTime).toEqual(dayjs('2014-01-06', 'YYYY-MM-DD').toDate()); + expect(tasks[4].endTime).toEqual(dayjs('2014-01-07', 'YYYY-MM-DD').toDate()); expect(tasks[4].order).toEqual(4); expect(tasks[4].id).toEqual('task1'); expect(tasks[4].task).toEqual('Completed task in the critical line'); - expect(tasks[5].startTime).toEqual(moment('2014-01-08', 'YYYY-MM-DD').toDate()); - expect(tasks[5].endTime).toEqual(moment('2014-01-10', 'YYYY-MM-DD').toDate()); + expect(tasks[5].startTime).toEqual(dayjs('2014-01-08', 'YYYY-MM-DD').toDate()); + expect(tasks[5].endTime).toEqual(dayjs('2014-01-10', 'YYYY-MM-DD').toDate()); expect(tasks[5].order).toEqual(5); expect(tasks[5].id).toEqual('task2'); expect(tasks[5].task).toEqual('Implement parser and jison'); - expect(tasks[6].startTime).toEqual(moment('2014-01-10', 'YYYY-MM-DD').toDate()); - expect(tasks[6].endTime).toEqual(moment('2014-01-13', 'YYYY-MM-DD').toDate()); + expect(tasks[6].startTime).toEqual(dayjs('2014-01-10', 'YYYY-MM-DD').toDate()); + expect(tasks[6].endTime).toEqual(dayjs('2014-01-13', 'YYYY-MM-DD').toDate()); expect(tasks[6].order).toEqual(6); expect(tasks[6].id).toEqual('task3'); expect(tasks[6].task).toEqual('Create tests for parser'); - expect(tasks[7].startTime).toEqual(moment('2014-01-13', 'YYYY-MM-DD').toDate()); - expect(tasks[7].endTime).toEqual(moment('2014-01-18', 'YYYY-MM-DD').toDate()); + expect(tasks[7].startTime).toEqual(dayjs('2014-01-13', 'YYYY-MM-DD').toDate()); + expect(tasks[7].endTime).toEqual(dayjs('2014-01-18', 'YYYY-MM-DD').toDate()); expect(tasks[7].order).toEqual(7); expect(tasks[7].id).toEqual('task4'); expect(tasks[7].task).toEqual('Future task in critical line'); - expect(tasks[8].startTime).toEqual(moment('2014-01-18', 'YYYY-MM-DD').toDate()); - expect(tasks[8].endTime).toEqual(moment('2014-01-20', 'YYYY-MM-DD').toDate()); + expect(tasks[8].startTime).toEqual(dayjs('2014-01-18', 'YYYY-MM-DD').toDate()); + expect(tasks[8].endTime).toEqual(dayjs('2014-01-20', 'YYYY-MM-DD').toDate()); expect(tasks[8].order).toEqual(8); expect(tasks[8].id).toEqual('task5'); expect(tasks[8].task).toEqual('Create tests for renderer'); - expect(tasks[9].startTime).toEqual(moment('2014-01-20', 'YYYY-MM-DD').toDate()); - expect(tasks[9].endTime).toEqual(moment('2014-01-21', 'YYYY-MM-DD').toDate()); + expect(tasks[9].startTime).toEqual(dayjs('2014-01-20', 'YYYY-MM-DD').toDate()); + expect(tasks[9].endTime).toEqual(dayjs('2014-01-21', 'YYYY-MM-DD').toDate()); expect(tasks[9].order).toEqual(9); expect(tasks[9].id).toEqual('task6'); expect(tasks[9].task).toEqual('Add to mermaid'); // Section - Documentation - expect(tasks[10].startTime).toEqual(moment('2014-01-08', 'YYYY-MM-DD').toDate()); - expect(tasks[10].endTime).toEqual(moment('2014-01-11', 'YYYY-MM-DD').toDate()); + expect(tasks[10].startTime).toEqual(dayjs('2014-01-08', 'YYYY-MM-DD').toDate()); + expect(tasks[10].endTime).toEqual(dayjs('2014-01-11', 'YYYY-MM-DD').toDate()); expect(tasks[10].order).toEqual(10); expect(tasks[10].id).toEqual('a1'); expect(tasks[10].task).toEqual('Describe gantt syntax'); - expect(tasks[11].startTime).toEqual(moment('2014-01-11', 'YYYY-MM-DD').toDate()); - expect(tasks[11].endTime).toEqual( - moment('2014-01-11 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate() - ); + expect(tasks[11].startTime).toEqual(dayjs('2014-01-11', 'YYYY-MM-DD').toDate()); + expect(tasks[11].endTime).toEqual(dayjs('2014-01-11 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate()); expect(tasks[11].order).toEqual(11); expect(tasks[11].id).toEqual('task7'); expect(tasks[11].task).toEqual('Add gantt diagram to demo page'); - expect(tasks[12].startTime).toEqual(moment('2014-01-11', 'YYYY-MM-DD').toDate()); - expect(tasks[12].endTime).toEqual(moment('2014-01-13', 'YYYY-MM-DD').toDate()); + expect(tasks[12].startTime).toEqual(dayjs('2014-01-11', 'YYYY-MM-DD').toDate()); + expect(tasks[12].endTime).toEqual(dayjs('2014-01-13', 'YYYY-MM-DD').toDate()); expect(tasks[12].order).toEqual(12); expect(tasks[12].id).toEqual('doc1'); expect(tasks[12].task).toEqual('Add another diagram to demo page'); // Section - Last section - expect(tasks[13].startTime).toEqual(moment('2014-01-13', 'YYYY-MM-DD').toDate()); - expect(tasks[13].endTime).toEqual(moment('2014-01-16', 'YYYY-MM-DD').toDate()); + expect(tasks[13].startTime).toEqual(dayjs('2014-01-13', 'YYYY-MM-DD').toDate()); + expect(tasks[13].endTime).toEqual(dayjs('2014-01-16', 'YYYY-MM-DD').toDate()); expect(tasks[13].order).toEqual(13); expect(tasks[13].id).toEqual('task8'); expect(tasks[13].task).toEqual('Describe gantt syntax'); - expect(tasks[14].startTime).toEqual(moment('2014-01-16', 'YYYY-MM-DD').toDate()); - expect(tasks[14].endTime).toEqual( - moment('2014-01-16 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate() - ); + expect(tasks[14].startTime).toEqual(dayjs('2014-01-16', 'YYYY-MM-DD').toDate()); + expect(tasks[14].endTime).toEqual(dayjs('2014-01-16 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate()); expect(tasks[14].order).toEqual(14); expect(tasks[14].id).toEqual('task9'); expect(tasks[14].task).toEqual('Add gantt diagram to demo page'); expect(tasks[15].startTime).toEqual( - moment('2014-01-16 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate() - ); - expect(tasks[15].endTime).toEqual( - moment('2014-01-18 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate() + dayjs('2014-01-16 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate() ); + expect(tasks[15].endTime).toEqual(dayjs('2014-01-18 20:00:00', 'YYYY-MM-DD HH:mm:ss').toDate()); expect(tasks[15].order).toEqual(15); expect(tasks[15].id).toEqual('task10'); expect(tasks[15].task).toEqual('Add another diagram to demo page'); @@ -358,19 +352,53 @@ describe('when using the ganttDb', function () { ganttDb.addTask('test2', 'id2,after id1,20d'); const tasks = ganttDb.getTasks(); - expect(tasks[0].startTime).toEqual(moment('2019-09-30', 'YYYY-MM-DD').toDate()); - expect(tasks[0].endTime).toEqual(moment('2019-10-11', 'YYYY-MM-DD').toDate()); + expect(tasks[0].startTime).toEqual(dayjs('2019-09-30', 'YYYY-MM-DD').toDate()); + expect(tasks[0].endTime).toEqual(dayjs('2019-10-11', 'YYYY-MM-DD').toDate()); expect(tasks[1].renderEndTime).toBeNull(); // Fixed end expect(tasks[0].id).toEqual('id1'); expect(tasks[0].task).toEqual('test1'); - expect(tasks[1].startTime).toEqual(moment('2019-10-11', 'YYYY-MM-DD').toDate()); - expect(tasks[1].endTime).toEqual(moment('2019-10-31', 'YYYY-MM-DD').toDate()); + expect(tasks[1].startTime).toEqual(dayjs('2019-10-11', 'YYYY-MM-DD').toDate()); + expect(tasks[1].endTime).toEqual(dayjs('2019-10-31', 'YYYY-MM-DD').toDate()); expect(tasks[1].renderEndTime).toBeNull(); // Fixed end expect(tasks[1].id).toEqual('id2'); expect(tasks[1].task).toEqual('test2'); }); + /** + * Unfortunately, Vitest has no way of modifying the timezone at runtime, so + * in order to test this, please run this test with + * + * ```bash + * TZ='America/Los_Angeles' pnpm exec vitest run ganttDb + * ``` + */ + /* c8 ignore start */ // tell code-coverage to ignore this block of code + describe.skipIf(process.env.TZ != 'America/Los_Angeles')( + 'when using a timezone with daylight savings (only run if TZ="America/Los_Angeles")', + () => { + it('should add 1 day even on days with 25 hours', function () { + const startTime = new Date(2020, 10, 1); + expect(startTime.toISOString()).toBe('2020-11-01T07:00:00.000Z'); + + const endTime = new Date(2020, 10, 2); + expect(endTime.toISOString()).toBe('2020-11-02T08:00:00.000Z'); + + ganttDb.setDateFormat('YYYY-MM-DD'); + ganttDb.addSection('Task handles 25 hour day'); + ganttDb.addTask('daylight savings day', 'id1,2020-11-01,1d'); + const tasks = ganttDb.getTasks(); + expect(tasks[0].startTime).toEqual(startTime); + expect(tasks[0].endTime).toEqual(endTime); + + // In USA states that use daylight savings, 2020-11-01 had 25 hours + const millisecondsIn25Hours = 25 * 60 * 60 * 1000; + expect(endTime - startTime).toEqual(millisecondsIn25Hours); + }); + } + ); + /* c8 ignore stop */ + describe('when setting inclusive end dates', function () { beforeEach(function () { ganttDb.setDateFormat('YYYY-MM-DD'); @@ -380,13 +408,13 @@ describe('when using the ganttDb', function () { }); it('should automatically add one day to all end dates', function () { const tasks = ganttDb.getTasks(); - expect(tasks[0].startTime).toEqual(moment('2019-02-01', 'YYYY-MM-DD').toDate()); - expect(tasks[0].endTime).toEqual(moment('2019-02-02', 'YYYY-MM-DD').toDate()); + expect(tasks[0].startTime).toEqual(dayjs('2019-02-01', 'YYYY-MM-DD').toDate()); + expect(tasks[0].endTime).toEqual(dayjs('2019-02-02', 'YYYY-MM-DD').toDate()); expect(tasks[0].id).toEqual('id1'); expect(tasks[0].task).toEqual('test1'); - expect(tasks[1].startTime).toEqual(moment('2019-02-01', 'YYYY-MM-DD').toDate()); - expect(tasks[1].endTime).toEqual(moment('2019-02-04', 'YYYY-MM-DD').toDate()); + expect(tasks[1].startTime).toEqual(dayjs('2019-02-01', 'YYYY-MM-DD').toDate()); + expect(tasks[1].endTime).toEqual(dayjs('2019-02-04', 'YYYY-MM-DD').toDate()); expect(tasks[1].renderEndTime).toBeNull(); // Fixed end expect(tasks[1].manualEndTime).toBeTruthy(); expect(tasks[1].id).toEqual('id2'); diff --git a/packages/mermaid/src/diagrams/gantt/ganttRenderer.js b/packages/mermaid/src/diagrams/gantt/ganttRenderer.js index faec35a86..7a012beb5 100644 --- a/packages/mermaid/src/diagrams/gantt/ganttRenderer.js +++ b/packages/mermaid/src/diagrams/gantt/ganttRenderer.js @@ -1,4 +1,4 @@ -import moment from 'moment-mini'; +import dayjs from 'dayjs'; import { log } from '../../logger'; import { select, @@ -435,16 +435,16 @@ export const draw = function (text, id, version, diagObj) { const excludeRanges = []; let range = null; - let d = moment(minTime); + let d = dayjs(minTime); while (d.valueOf() <= maxTime) { if (diagObj.db.isInvalidDate(d, dateFormat, excludes, includes)) { if (!range) { range = { - start: d.clone(), - end: d.clone(), + start: d, + end: d, }; } else { - range.end = d.clone(); + range.end = d; } } else { if (range) { @@ -452,7 +452,7 @@ export const draw = function (text, id, version, diagObj) { range = null; } } - d.add(1, 'd'); + d = d.add(1, 'd'); } const rectangles = svg.append('g').selectAll('rect').data(excludeRanges).enter(); @@ -467,7 +467,7 @@ export const draw = function (text, id, version, diagObj) { }) .attr('y', conf.gridLineStartPadding) .attr('width', function (d) { - const renderEnd = d.end.clone().add(1, 'day'); + const renderEnd = d.end.add(1, 'day'); return timeScale(renderEnd) - timeScale(d.start); }) .attr('height', h - theTopPad - conf.gridLineStartPadding) diff --git a/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts b/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts index 02e706bf6..272ecf0c1 100644 --- a/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts +++ b/packages/mermaid/src/diagrams/timeline/timelineRenderer.ts @@ -1,25 +1,37 @@ -// @ts-nocheck TODO: fix file -import { select } from 'd3'; +// @ts-ignore - db not typed yet +import { select, Selection } from 'd3'; import svgDraw from './svgDraw'; import { log } from '../../logger'; import { getConfig } from '../../config'; import { setupGraphViewbox } from '../../setupGraphViewbox'; +import { Diagram } from '../../Diagram'; +import { MermaidConfig } from '../../config.type'; -export const setConf = function (cnf) { - const keys = Object.keys(cnf); +interface Block { + number: number; + descr: TDesc; + section: TSection; + width: number; + padding: number; + maxHeight: number; +} - keys.forEach(function (key) { - conf[key] = cnf[key]; - }); -}; - -export const draw = function (text, id, version, diagObj) { +interface TimelineTask { + id: number; + section: string; + type: string; + task: string; + score: number; + events: string[]; +} +export const draw = function (text: string, id: string, version: string, diagObj: Diagram) { //1. Fetch the configuration const conf = getConfig(); - const LEFT_MARGIN = conf.leftMargin ? conf.leftMargin : 50; + // @ts-expect-error - wrong config? + const LEFT_MARGIN = conf.leftMargin ?? 50; //2. Clear the diagram db before parsing - diagObj.db.clear(); + diagObj.db.clear?.(); //3. Parse the diagram text diagObj.parser.parse(text + '\n'); @@ -34,15 +46,19 @@ export const draw = function (text, id, version, diagObj) { } const root = securityLevel === 'sandbox' - ? select(sandboxElement.nodes()[0].contentDocument.body) + ? // @ts-ignore d3 types are wrong + select(sandboxElement.nodes()[0].contentDocument.body) : select('body'); + // @ts-ignore d3 types are wrong const svg = root.select('#' + id); svg.append('g'); //4. Fetch the diagram data - const tasks = diagObj.db.getTasks(); + // @ts-expect-error - db not typed yet + const tasks: TimelineTask[] = diagObj.db.getTasks(); + // @ts-expect-error - db not typed yet const title = diagObj.db.getCommonDb().getDiagramTitle(); log.debug('task', tasks); @@ -50,7 +66,8 @@ export const draw = function (text, id, version, diagObj) { svgDraw.initGraphics(svg); // fetch Sections - const sections = diagObj.db.getSections(); + // @ts-expect-error - db not typed yet + const sections: string[] = diagObj.db.getSections(); log.debug('sections', sections); let maxSectionHeight = 0; @@ -67,8 +84,8 @@ export const draw = function (text, id, version, diagObj) { let hasSections = true; //Calculate the max height of the sections - sections.forEach(function (section) { - const sectionNode = { + sections.forEach(function (section: string) { + const sectionNode: Block = { number: sectionNumber, descr: section, section: sectionNumber, @@ -87,8 +104,9 @@ export const draw = function (text, id, version, diagObj) { log.debug('tasks.length', tasks.length); //calculate max task height // for loop till tasks.length + for (const [i, task] of tasks.entries()) { - const taskNode = { + const taskNode: Block = { number: i, descr: task, section: task.section, @@ -124,11 +142,14 @@ export const draw = function (text, id, version, diagObj) { if (sections && sections.length > 0) { sections.forEach((section) => { - const sectionNode = { + //filter task where tasks.section == section + const tasksForSection = tasks.filter((task) => task.section === section); + + const sectionNode: Block = { number: sectionNumber, descr: section, section: sectionNumber, - width: 150, + width: 200 * Math.max(tasksForSection.length, 1) - 50, padding: 20, maxHeight: maxSectionHeight, }; @@ -142,8 +163,6 @@ export const draw = function (text, id, version, diagObj) { masterY += maxSectionHeight + 50; //draw tasks for this section - //filter task where tasks.section == section - const tasksForSection = tasks.filter((task) => task.section === section); if (tasksForSection.length > 0) { drawTasks( svg, @@ -215,25 +234,25 @@ export const draw = function (text, id, version, diagObj) { setupGraphViewbox( undefined, svg, - conf.timeline.padding ? conf.timeline.padding : 50, - conf.timeline.useMaxWidth ? conf.timeline.useMaxWidth : false + conf.timeline?.padding ?? 50, + conf.timeline?.useMaxWidth ?? false ); // addSVGAccessibilityFields(diagObj.db, diagram, id); }; export const drawTasks = function ( - diagram, - tasks, - sectionColor, - masterX, - masterY, - maxTaskHeight, - conf, - maxEventCount, - maxEventLineLength, - maxSectionHeight, - isWithoutSections + diagram: Selection, + tasks: TimelineTask[], + sectionColor: number, + masterX: number, + masterY: number, + maxTaskHeight: number, + conf: MermaidConfig, + maxEventCount: number, + maxEventLineLength: number, + maxSectionHeight: number, + isWithoutSections: boolean ) { // Draw the tasks for (const task of tasks) { @@ -249,6 +268,7 @@ export const drawTasks = function ( log.debug('taskNode', taskNode); // create task wrapper + const taskWrapper = diagram.append('g').attr('class', 'taskWrapper'); const node = svgDraw.drawNode(taskWrapper, taskNode, sectionColor, conf); const taskHeight = node.height; @@ -263,11 +283,11 @@ export const drawTasks = function ( if (task.events) { // draw a line between the task and the events const lineWrapper = diagram.append('g').attr('class', 'lineWrapper'); - let linelength = maxTaskHeight; + let lineLength = maxTaskHeight; //add margin to task masterY += 100; - linelength = - linelength + drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf); + lineLength = + lineLength + drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf); masterY -= 100; lineWrapper @@ -290,7 +310,7 @@ export const drawTasks = function ( } masterX = masterX + 200; - if (isWithoutSections && !getConfig().timeline.disableMulticolor) { + if (isWithoutSections && !conf.timeline?.disableMulticolor) { sectionColor++; } } @@ -299,14 +319,21 @@ export const drawTasks = function ( masterY = masterY - 10; }; -export const drawEvents = function (diagram, events, sectionColor, masterX, masterY, conf) { +export const drawEvents = function ( + diagram: Selection, + events: string[], + sectionColor: number, + masterX: number, + masterY: number, + conf: MermaidConfig +) { let maxEventHeight = 0; const eventBeginY = masterY; masterY = masterY + 100; // Draw the events for (const event of events) { // create node from event - const eventNode = { + const eventNode: Block = { descr: event, section: sectionColor, number: sectionColor, @@ -331,6 +358,8 @@ export const drawEvents = function (diagram, events, sectionColor, masterX, mast }; export default { - setConf, + setConf: () => { + // no-op + }, draw, }; diff --git a/packages/mermaid/src/diagrams/user-journey/journeyRenderer.ts b/packages/mermaid/src/diagrams/user-journey/journeyRenderer.ts index df46fc9c6..c34f8f5b2 100644 --- a/packages/mermaid/src/diagrams/user-journey/journeyRenderer.ts +++ b/packages/mermaid/src/diagrams/user-journey/journeyRenderer.ts @@ -224,6 +224,17 @@ export const drawTasks = function (diagram, tasks, verticalPos) { num = sectionNumber % fills.length; colour = textColours[sectionNumber % textColours.length]; + // count how many consecutive tasks have the same section + let taskInSectionCount = 0; + const currentSection = task.section; + for (let taskIndex = i; taskIndex < tasks.length; taskIndex++) { + if (tasks[taskIndex].section == currentSection) { + taskInSectionCount = taskInSectionCount + 1; + } else { + break; + } + } + const section = { x: i * conf.taskMargin + i * conf.width + LEFT_MARGIN, y: 50, @@ -231,6 +242,7 @@ export const drawTasks = function (diagram, tasks, verticalPos) { fill, num, colour, + taskCount: taskInSectionCount, }; svgDraw.drawSection(diagram, section, conf); diff --git a/packages/mermaid/src/diagrams/user-journey/svgDraw.js b/packages/mermaid/src/diagrams/user-journey/svgDraw.js index 74d5d2a02..f6dbe71e1 100644 --- a/packages/mermaid/src/diagrams/user-journey/svgDraw.js +++ b/packages/mermaid/src/diagrams/user-journey/svgDraw.js @@ -196,7 +196,10 @@ export const drawSection = function (elem, section, conf) { rect.x = section.x; rect.y = section.y; rect.fill = section.fill; - rect.width = conf.width; + // section width covers all nested tasks + rect.width = + conf.width * section.taskCount + // width of the tasks + conf.diagramMarginX * (section.taskCount - 1); // width of space between tasks rect.height = conf.height; rect.class = 'journey-section section-type-' + section.num; rect.rx = 3; diff --git a/packages/mermaid/src/docs/syntax/classDiagram.md b/packages/mermaid/src/docs/syntax/classDiagram.md index 10ccc3522..9d3766590 100644 --- a/packages/mermaid/src/docs/syntax/classDiagram.md +++ b/packages/mermaid/src/docs/syntax/classDiagram.md @@ -76,6 +76,26 @@ classDiagram Naming convention: a class name should be composed only of alphanumeric characters (including unicode), and underscores. +### Class labels + +In case you need to provide a label for a class, you can use the following syntax: + +```mermaid-example +classDiagram + class Animal["Animal with a label"] + class Car["Car with *! symbols"] + Animal --> Car +``` + +You can also use backticks to escape special characters in the label: + +```mermaid-example +classDiagram + class `Animal Class!` + class `Car Class` + `Animal Class!` --> `Car Class` +``` + ## Defining Members of a class UML provides mechanisms to represent class members such as attributes and methods, as well as additional information about them. @@ -477,11 +497,11 @@ Beginner's tip—a full example using interactive links in an HTML page: ### Styling a node -It is possible to apply specific styles such as a thicker border or a different background color to individual nodes. This is done by predefining classes in css styles that can be applied from the graph definition: +It is possible to apply specific styles such as a thicker border or a different background color to individual nodes. This is done by predefining classes in css styles that can be applied from the graph definition using the `cssClass` statement or the `:::` short hand. ```html