MermaidBlock.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { useEffect, useRef, useState } from "react"
  2. import mermaid from "mermaid"
  3. import { useDebounceEffect } from "../../utils/useDebounceEffect"
  4. import styled from "styled-components"
  5. import { vscode } from "../../utils/vscode"
  6. const MERMAID_THEME = {
  7. background: "#1e1e1e", // VS Code dark theme background
  8. textColor: "#ffffff", // Main text color
  9. mainBkg: "#2d2d2d", // Background for nodes
  10. nodeBorder: "#888888", // Border color for nodes
  11. lineColor: "#cccccc", // Lines connecting nodes
  12. primaryColor: "#3c3c3c", // Primary color for highlights
  13. primaryTextColor: "#ffffff", // Text in primary colored elements
  14. primaryBorderColor: "#888888",
  15. secondaryColor: "#2d2d2d", // Secondary color for alternate elements
  16. tertiaryColor: "#454545", // Third color for special elements
  17. // Class diagram specific
  18. classText: "#ffffff",
  19. // State diagram specific
  20. labelColor: "#ffffff",
  21. // Sequence diagram specific
  22. actorLineColor: "#cccccc",
  23. actorBkg: "#2d2d2d",
  24. actorBorder: "#888888",
  25. actorTextColor: "#ffffff",
  26. // Flow diagram specific
  27. fillType0: "#2d2d2d",
  28. fillType1: "#3c3c3c",
  29. fillType2: "#454545",
  30. }
  31. mermaid.initialize({
  32. startOnLoad: false,
  33. securityLevel: "loose",
  34. theme: "dark",
  35. themeVariables: {
  36. ...MERMAID_THEME,
  37. fontSize: "16px",
  38. fontFamily: "var(--vscode-font-family, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif)",
  39. // Additional styling
  40. noteTextColor: "#ffffff",
  41. noteBkgColor: "#454545",
  42. noteBorderColor: "#888888",
  43. // Improve contrast for special elements
  44. critBorderColor: "#ff9580",
  45. critBkgColor: "#803d36",
  46. // Task diagram specific
  47. taskTextColor: "#ffffff",
  48. taskTextOutsideColor: "#ffffff",
  49. taskTextLightColor: "#ffffff",
  50. // Numbers/sections
  51. sectionBkgColor: "#2d2d2d",
  52. sectionBkgColor2: "#3c3c3c",
  53. // Alt sections in sequence diagrams
  54. altBackground: "#2d2d2d",
  55. // Links
  56. linkColor: "#6cb6ff",
  57. // Borders and lines
  58. compositeBackground: "#2d2d2d",
  59. compositeBorder: "#888888",
  60. titleColor: "#ffffff",
  61. },
  62. })
  63. interface MermaidBlockProps {
  64. code: string
  65. }
  66. export default function MermaidBlock({ code }: MermaidBlockProps) {
  67. const containerRef = useRef<HTMLDivElement>(null)
  68. const [isLoading, setIsLoading] = useState(false)
  69. // 1) Whenever `code` changes, mark that we need to re-render a new chart
  70. useEffect(() => {
  71. setIsLoading(true)
  72. }, [code])
  73. // 2) Debounce the actual parse/render
  74. useDebounceEffect(
  75. () => {
  76. if (containerRef.current) {
  77. containerRef.current.innerHTML = ""
  78. }
  79. mermaid
  80. .parse(code, { suppressErrors: true })
  81. .then((isValid) => {
  82. if (!isValid) {
  83. throw new Error("Invalid or incomplete Mermaid code")
  84. }
  85. const id = `mermaid-${Math.random().toString(36).substring(2)}`
  86. return mermaid.render(id, code)
  87. })
  88. .then(({ svg }) => {
  89. if (containerRef.current) {
  90. containerRef.current.innerHTML = svg
  91. }
  92. })
  93. .catch((err) => {
  94. console.warn("Mermaid parse/render failed:", err)
  95. containerRef.current!.innerHTML = code.replace(/</g, "&lt;").replace(/>/g, "&gt;")
  96. })
  97. .finally(() => {
  98. setIsLoading(false)
  99. })
  100. },
  101. 500, // Delay 500ms
  102. [code], // Dependencies for scheduling
  103. )
  104. /**
  105. * Called when user clicks the rendered diagram.
  106. * Converts the <svg> to a PNG and sends it to the extension.
  107. */
  108. const handleClick = async () => {
  109. if (!containerRef.current) return
  110. const svgEl = containerRef.current.querySelector("svg")
  111. if (!svgEl) return
  112. try {
  113. const pngDataUrl = await svgToPng(svgEl)
  114. vscode.postMessage({
  115. type: "openImage",
  116. text: pngDataUrl,
  117. })
  118. } catch (err) {
  119. console.error("Error converting SVG to PNG:", err)
  120. }
  121. }
  122. return (
  123. <MermaidBlockContainer>
  124. {isLoading && <LoadingMessage>Generating mermaid diagram...</LoadingMessage>}
  125. {/* The container for the final <svg> or raw code. */}
  126. <SvgContainer onClick={handleClick} ref={containerRef} $isLoading={isLoading} />
  127. </MermaidBlockContainer>
  128. )
  129. }
  130. async function svgToPng(svgEl: SVGElement): Promise<string> {
  131. console.log("svgToPng function called")
  132. // Clone the SVG to avoid modifying the original
  133. const svgClone = svgEl.cloneNode(true) as SVGElement
  134. // Get the original viewBox
  135. const viewBox = svgClone.getAttribute("viewBox")?.split(" ").map(Number) || []
  136. const originalWidth = viewBox[2] || svgClone.clientWidth
  137. const originalHeight = viewBox[3] || svgClone.clientHeight
  138. // Calculate the scale factor to fit editor width while maintaining aspect ratio
  139. // Unless we can find a way to get the actual editor window dimensions through the VS Code API (which might be possible but would require changes to the extension side),
  140. // the fixed width seems like a reliable approach.
  141. const editorWidth = 3_600
  142. const scale = editorWidth / originalWidth
  143. const scaledHeight = originalHeight * scale
  144. // Update SVG dimensions
  145. svgClone.setAttribute("width", `${editorWidth}`)
  146. svgClone.setAttribute("height", `${scaledHeight}`)
  147. const serializer = new XMLSerializer()
  148. const svgString = serializer.serializeToString(svgClone)
  149. const svgDataUrl = "data:image/svg+xml;base64," + btoa(decodeURIComponent(encodeURIComponent(svgString)))
  150. return new Promise((resolve, reject) => {
  151. const img = new Image()
  152. img.onload = () => {
  153. const canvas = document.createElement("canvas")
  154. canvas.width = editorWidth
  155. canvas.height = scaledHeight
  156. const ctx = canvas.getContext("2d")
  157. if (!ctx) return reject("Canvas context not available")
  158. // Fill background with Mermaid's dark theme background color
  159. ctx.fillStyle = MERMAID_THEME.background
  160. ctx.fillRect(0, 0, canvas.width, canvas.height)
  161. ctx.imageSmoothingEnabled = true
  162. ctx.imageSmoothingQuality = "high"
  163. ctx.drawImage(img, 0, 0, editorWidth, scaledHeight)
  164. resolve(canvas.toDataURL("image/png", 1.0))
  165. }
  166. img.onerror = reject
  167. img.src = svgDataUrl
  168. })
  169. }
  170. const MermaidBlockContainer = styled.div`
  171. position: relative;
  172. margin: 8px 0;
  173. `
  174. const LoadingMessage = styled.div`
  175. padding: 8px 0;
  176. color: var(--vscode-descriptionForeground);
  177. font-style: italic;
  178. font-size: 0.9em;
  179. `
  180. interface SvgContainerProps {
  181. $isLoading: boolean
  182. }
  183. const SvgContainer = styled.div<SvgContainerProps>`
  184. opacity: ${(props) => (props.$isLoading ? 0.3 : 1)};
  185. min-height: 20px;
  186. transition: opacity 0.2s ease;
  187. cursor: pointer;
  188. display: flex;
  189. justify-content: center;
  190. `