diff --git a/playground/testcases/state.ts b/playground/testcases/state.ts index c9baf750..3ed459bb 100644 --- a/playground/testcases/state.ts +++ b/playground/testcases/state.ts @@ -174,6 +174,59 @@ export const STATE_DIAGRAM_TESTCASES = [ } `, }, + + { + name: "Notes", + definition: `stateDiagram-v2 + State1: The state with a note + note right of State1 + Important information! You can write + notes. + end note + State1 --> State2 + note left of State2 : This is the note to the left. + `, + }, + { + name: "Note left and right in same state", + definition: `stateDiagram-v2 + State1: The state with a note + note right of State1 + Important information! You can write + notes. + end note + note left of State1 : This is the note to the left. + `, + }, + { + name: "Multiple notes in the same state", + definition: `stateDiagram-v2 + State1: The state with a note + note right of State1 + Important information! You can write + notes. + end note + note left of State1 : Left. + note left of State1 : Join. + note left of State1 : Out. + note right of State1 : Ok!. + `, + }, + { + name: "Notes inside a composite state", + definition: `stateDiagram-v2 + [*] --> First + state First { + [*] --> second + second --> [*] + + note right of second + First is a composite state + end note + + } + `, + }, { name: "Sample 1", definition: `stateDiagram-v2 diff --git a/src/converter/types/state.ts b/src/converter/types/state.ts index 04a2ca50..ebc0f619 100644 --- a/src/converter/types/state.ts +++ b/src/converter/types/state.ts @@ -23,9 +23,11 @@ export const StateToExcalidrawSkeletonConvertor = new GraphConverter({ case "rectangle": const element = transformToExcalidrawContainerSkeleton(node); - Object.assign(element, { - roundness: { type: 3 }, - }); + if (!element.id?.includes("note")) { + Object.assign(element, { + roundness: { type: 3 }, + }); + } elements.push(element); break; @@ -43,6 +45,10 @@ export const StateToExcalidrawSkeletonConvertor = new GraphConverter({ }); chart.edges.forEach((edge) => { + if (!edge) { + return; + } + const points = edge.reflectionPoints.map((point: Point) => [ point.x - edge.reflectionPoints[0].x, point.y - edge.reflectionPoints[0].y, @@ -61,6 +67,11 @@ export const StateToExcalidrawSkeletonConvertor = new GraphConverter({ return; } + if (endVertex.id?.includes("note") || startVertex.id?.includes("note")) { + arrow.endArrowhead = null; + arrow.strokeStyle = "dashed"; + } + arrow.start = { id: startVertex.id, type: "rectangle", diff --git a/src/parser/state.ts b/src/parser/state.ts index 4c244e9d..b3dea821 100644 --- a/src/parser/state.ts +++ b/src/parser/state.ts @@ -1,10 +1,12 @@ import type { Diagram } from "mermaid/dist/Diagram.js"; import { - Line, - createContainerSkeletonFromSVG, + type Container, + type Line, type Node, + createContainerSkeletonFromSVG, } from "../elementSkeleton.js"; -import { computeEdge2Positions, computeElementPosition } from "../utils.js"; +import { computeEdgePositions, computeElementPosition } from "../utils.js"; +import type { Edge } from "./flowchart.js"; export interface State { type: "state"; @@ -44,23 +46,69 @@ export interface RelationState { } export interface CompositeState { - doc?: Array; + doc: Array; + description: string; + id: string; + type: "default"; + stmt: "state"; +} + +export interface SingleState { description: string; id: string; type: "default"; stmt: "state"; } +export interface NoteState { + id: string; + note: { + position: string; + text: string; + }; + stmt: "state"; +} + export interface SpecialState { id: string; type: "choice" | "fork" | "join"; stmt: "state"; } -export type ParsedDoc = SpecialState | CompositeState | RelationState; +export type ParsedDoc = + | NoteState + | SpecialState + | SingleState + | CompositeState + | RelationState; const MARGIN_TOP_LINE_X_AXIS = 25; +const isNoteState = (node: ParsedDoc): node is NoteState => { + return node.stmt === "state" && "note" in node; +}; + +const isCompositeState = (node: ParsedDoc): node is CompositeState => { + return node.stmt === "state" && "doc" in node; +}; + +const isSingleState = (node: ParsedDoc): node is SingleState => { + return ( + node.stmt === "state" && + "doc" in node === false && + "type" in node && + node.type === "default" + ); +}; + +const isSpecialState = (node: ParsedDoc): node is SpecialState => { + return node.stmt === "state" && "type" in node && node.type !== "default"; +}; + +const isRelationState = (node: ParsedDoc): node is RelationState => { + return node.stmt === "relation"; +}; + const createInnerEllipseExcalidrawElement = ( element: SVGSVGElement, position: { x: number; y: number; width: number; height: number }, @@ -184,7 +232,7 @@ const parseRelation = ( } if ( - !processedNodeRelations.has(relationStartNode.id) && + !processedNodeRelations.has(relationStart.id) && !isRelationStartCluster ) { const relationStartContainer = createExcalidrawElement( @@ -218,13 +266,10 @@ const parseRelation = ( relationStartContainer.groupId = groupId; nodes.push(relationStartContainer); - processedNodeRelations.add(relationStartNode.id); + processedNodeRelations.add(relationStart.id); } - if ( - !processedNodeRelations.has(relationEndNode.id) && - !isRelationEndCluster - ) { + if (!processedNodeRelations.has(relationEnd.id) && !isRelationEndCluster) { const relationEndContainer = createExcalidrawElement( relationEndNode, containerEl, @@ -238,7 +283,7 @@ const parseRelation = ( relationEndContainer.groupId = groupId; nodes.push(relationEndContainer); - processedNodeRelations.add(relationEndNode.id); + processedNodeRelations.add(relationEnd.id); if (relationEnd?.start === false) { const innerEllipse = createInnerEllipseExcalidrawElement( @@ -277,10 +322,8 @@ const parseDoc = ( groupId?: string ) => { doc.forEach((state) => { - const isSingleState = - state.stmt === "state" && state.type === "default" && !state?.doc; - - if (isSingleState) { + if (isSingleState(state)) { + console.debug(state); const singleStateNode = containerEl.querySelector( `[data-id="${state.id}"]` )!; @@ -292,15 +335,16 @@ const parseDoc = ( { label: { text: state.description || state.id } } ); + processedNodeRelations.add(state.id); nodes.push(stateElement); } - if (state.stmt === "state" && state.type !== "default") { + if (isSpecialState(state)) { specialTypes[state.id] = state; + return; } - // Relation state - if (state.stmt === "relation") { + if (isRelationState(state)) { parseRelation( state, containerEl, @@ -311,8 +355,7 @@ const parseDoc = ( ); } - // Composite state - if (state.stmt === "state" && state.type === "default" && state?.doc) { + if (isCompositeState(state)) { const clusterElement = getClusterElement(containerEl, state.id); const { clusterElementSkeleton, topLine } = @@ -344,9 +387,13 @@ const parseEdges = (nodes: ParsedDoc[], containerEl: Element): any[] => { clusterId?: string ): any[] { return nodes - .filter((node) => !(node.stmt === "state" && node.type !== "default")) + .filter((node) => { + return ( + isCompositeState(node) || isRelationState(node) || isNoteState(node) + ); + }) .flatMap((node, index) => { - if (node.stmt === "state" && node.type === "default" && node.doc) { + if (isCompositeState(node)) { const clusters = getClusterElement(containerEl, node.id)?.closest( ".root" ); @@ -403,11 +450,11 @@ const parseEdges = (nodes: ParsedDoc[], containerEl: Element): any[] => { edgeStartElement, containerEl ); - const edgePositionData = computeEdge2Positions( + const edgePositionData = computeEdgePositions( edgeStartElement, - position + position, + "MC" ); - /** * Edge case where cluster don't have the .edgePaths in SVG, * so we need to increment the index manually and get from the root container svg @@ -425,7 +472,10 @@ const parseEdges = (nodes: ParsedDoc[], containerEl: Element): any[] => { }; } - // This is neither a "state" node nor a "relation" node. Return an empty array. + if (isNoteState(node)) { + rootEdgeIndex++; + } + return []; }); } @@ -433,6 +483,102 @@ const parseEdges = (nodes: ParsedDoc[], containerEl: Element): any[] => { return parse(nodes); }; +const parseNotes = (doc: ParsedDoc[], containerEl: Element) => { + let rootIndex = 0; + const noteIndex: Record = {}; + const notes: Container[] = []; + const edges: Partial[] = []; + + const processNote = (state: NoteState): [Container, Partial] => { + if (!noteIndex[state.id]) { + noteIndex[state.id] = 0; + } + const noteNodes = Array.from( + containerEl.querySelectorAll( + `[data-id*="${state.id}----note"]` + ) + ); + + const noteNode = noteNodes[noteIndex[state.id]]; + + const noteElement = createExcalidrawElement( + noteNode, + containerEl, + "rectangle", + { + label: { text: state.note.text }, + id: noteNode.id, + subtype: "note", + } + ); + + const rootContainer = noteNode.closest(".root")!; + + const edge = rootContainer.querySelector(".edgePaths")?.children[ + rootIndex + ] as SVGPathElement; + + const position = computeElementPosition(edge, containerEl); + + const edgePositionData = computeEdgePositions(edge, position, "MCL"); + + let startNode = rootContainer.querySelector( + `[data-id*="${state.id}"]:not([data-id*="note"])` + )!; + + const isClusterStartRelation = + startNode.id.includes(`_start`) || startNode.id.includes(`_end`); + + if (isClusterStartRelation) { + startNode = getClusterElement(containerEl, state.id); + } + + const edgeElement: Partial = { + start: startNode.id, + end: noteNode.id, + ...edgePositionData, + }; + + if (state.note.position.includes("left")) { + edgeElement.end = startNode.id; + edgeElement.start = noteNode.id; + } + + noteIndex[state.id]++; + return [noteElement, edgeElement]; + }; + + doc + .filter( + (state) => + isNoteState(state) || isCompositeState(state) || isRelationState(state) + ) + .flatMap((state) => { + if (isNoteState(state)) { + const [noteElement, edgeElement] = processNote(state); + + rootIndex++; + notes.push(noteElement); + edges.push(edgeElement); + } + + if (isCompositeState(state)) { + const { notes: compositeNotes, edges: compositeEdges } = parseNotes( + state.doc, + containerEl + ); + notes.push(...compositeNotes); + edges.push(...compositeEdges); + } + + if (isRelationState(state)) { + rootIndex++; + } + }); + + return { notes, edges }; +}; + export const parseMermaidStateDiagram = ( diagram: Diagram, containerEl: Element @@ -457,5 +603,10 @@ export const parseMermaidStateDiagram = ( const nodes = parseDoc(rootDocV2.doc, containerEl); const edges = parseEdges(rootDocV2.doc, containerEl); + const { notes, edges: edgeNotes } = parseNotes(rootDocV2.doc, containerEl); + + nodes.push(...notes); + edges.push(...edgeNotes); + return { type: "state", nodes, edges }; }; diff --git a/src/utils.ts b/src/utils.ts index 19af0584..944f4e22 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -70,7 +70,8 @@ interface EdgePositionData { // Compute edge postion start, end and points (reflection points) export const computeEdgePositions = ( pathElement: SVGPathElement, - offset: Position = { x: 0, y: 0 } + offset: Position = { x: 0, y: 0 }, + commandsPattern = "LM" ): EdgePositionData => { // Check if the element is a path else throw an error if (pathElement.tagName.toLowerCase() !== "path") { @@ -85,82 +86,9 @@ export const computeEdgePositions = ( throw new Error('Path element does not contain a "d" attribute'); } - // Split the d attribute based on M (Move To) and L (Line To) commands - // eg "M29.383,38.5L29.383,63.5L29.383,83.2" => ["M29.383,38.5", "L29.383,63.5", "L29.383,83.2"] - const commands = dAttr.split(/(?=[LM])/); - - // Get the start position from the first commands element => [29.383,38.5] - const startPosition = commands[0] - .substring(1) - .split(",") - .map((coord) => parseFloat(coord)); - - // Get the last position from the last commands element => [29.383,83.2] - const endPosition = commands[commands.length - 1] - .substring(1) - .split(",") - .map((coord) => parseFloat(coord)); - - // compute the reflection points -> [ {x: 29.383, y: 38.5}, {x: 29.383, y: 83.2} ] - // These includes the start and end points and also points which are not the same as the previous points - const reflectionPoints = commands - .map((command) => { - const coords = command - .substring(1) - .split(",") - .map((coord) => parseFloat(coord)); - - return { x: coords[0], y: coords[1] }; - }) - .filter((point, index, array) => { - // Always include the last point - if (index === array.length - 1) { - return true; - } - // Include the start point or if the current point if it's not the same as the previous point - const prevPoint = array[index - 1]; - return ( - index === 0 || (point.x !== prevPoint.x && point.y !== prevPoint.y) - ); - }) - .map((p) => { - // Offset the point by the provided offset - return { - x: p.x + offset.x, - y: p.y + offset.y, - }; - }); - - // Return the edge positions - return { - startX: startPosition[0] + offset.x, - startY: startPosition[1] + offset.y, - endX: endPosition[0] + offset.x, - endY: endPosition[1] + offset.y, - reflectionPoints, - }; -}; - -export const computeEdge2Positions = ( - pathElement: SVGPathElement, - offset: Position = { x: 0, y: 0 } -): EdgePositionData => { - // Check if the element is a path else throw an error - if (pathElement.tagName.toLowerCase() !== "path") { - throw new Error( - `Invalid input: Expected an HTMLElement of tag "path", got ${pathElement.tagName}` - ); - } - - // Get the d attribute from the path element else throw an error - const dAttr = pathElement.getAttribute("d"); - if (!dAttr) { - throw new Error('Path element does not contain a "d" attribute'); - } - - // Split the d attribute based on M (Move To) and L (Line To) commands - // eg "M29.383,38.5L29.383,63.5L29.383,83.2" => ["M29.383,38.5", "L29.383,63.5", "L29.383,83.2"] - const commands = dAttr.split(/(?=[MC])/); + // Split the d attribute based on some commands: M (Move To), L (Line To) commands and if specifies C (Curve To) commands + // eg "M29.383,38.5L29.383,63.5L29.383,83.2" => ["M29.383,38.5", "L29.383,63.5", "L29.383,83.2", "C29.383,83.2"] + const commands = dAttr.split(new RegExp(`(?=[${commandsPattern}])`)); // Get the start position from the first commands element => [29.383,38.5] const startPosition = commands[0]