From d0ee764732295491ec1bb727f1d9a9fa19ddb22b Mon Sep 17 00:00:00 2001 From: Yrom Date: Tue, 18 Feb 2025 12:39:08 +0800 Subject: [PATCH] feat: Add PlantUML diagram support (via PlantUML official online server) --- package.json | 1 + src/renderer/src/i18n/locales/en-us.json | 12 + src/renderer/src/i18n/locales/zh-cn.json | 12 + .../src/pages/home/Markdown/CodeBlock.tsx | 5 + .../src/pages/home/Markdown/PlantUML.tsx | 275 ++++++++++++++++++ yarn.lock | 8 + 6 files changed, 313 insertions(+) create mode 100644 src/renderer/src/pages/home/Markdown/PlantUML.tsx diff --git a/package.json b/package.json index ab4aaf18..3eedad78 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@types/markdown-it": "^14", "@types/md5": "^2.3.5", "@types/node": "^18.19.9", + "@types/pako": "^1.0.2", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/react-infinite-scroll-component": "^5.0.0", diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index cebd70af..972e22df 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -335,6 +335,18 @@ }, "title": "Mermaid Diagram" }, + "plantuml": { + "download": { + "png": "Download PNG", + "svg": "Download SVG", + "failed": "Download failed, please check the network" + }, + "tabs": { + "preview": "Preview", + "source": "Source" + }, + "title": "PlantUML Diagram" + }, "message": { "api.check.model.title": "Select the model to use for detection", "api.connection.failed": "Connection failed", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6db008c1..c6586dc7 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -335,6 +335,18 @@ }, "title": "Mermaid 图表" }, + "plantuml": { + "title": "PlantUML 图表", + "download": { + "png": "下载 PNG", + "svg": "下载 SVG", + "failed": "下载失败,请检查网络" + }, + "tabs": { + "preview": "预览", + "source": "源码" + } + }, "message": { "api.check.model.title": "请选择要检测的模型", "api.connection.failed": "连接失败", diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index 38037a1d..a6f43313 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -10,6 +10,7 @@ import styled from 'styled-components' import Artifacts from './Artifacts' import Mermaid from './Mermaid' +import { isValidPlantUML, PlantUML } from './PlantUML' import SvgPreview from './SvgPreview' interface CodeBlockProps { @@ -62,6 +63,10 @@ const CodeBlock: React.FC = ({ children, className }) => { return } + if (language === 'plantuml' && isValidPlantUML(children)) { + return + } + if (language === 'svg') { return ( diff --git a/src/renderer/src/pages/home/Markdown/PlantUML.tsx b/src/renderer/src/pages/home/Markdown/PlantUML.tsx new file mode 100644 index 00000000..ae7b8ad5 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/PlantUML.tsx @@ -0,0 +1,275 @@ +import { CopyOutlined, LoadingOutlined } from '@ant-design/icons' +import { TopView } from '@renderer/components/TopView' +import { Button, Modal, Space, Spin, Tabs } from 'antd' +import pako from 'pako' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface PlantUMLPopupProps { + resolve: (data: any) => void + diagram: string +} +export function isValidPlantUML(diagram: string | null): boolean { + if (!diagram || !diagram.trim().startsWith('@start')) { + return false + } + const diagramType = diagram.match(/@start(\w+)/)?.[1] + + return diagramType !== undefined && diagram.search(`@end${diagramType}`) !== -1 +} + +const PlantUMLServer = 'https://www.plantuml.com/plantuml' +function encode64(data: Uint8Array) { + let r = '' + for (let i = 0; i < data.length; i += 3) { + if (i + 2 === data.length) { + r += append3bytes(data[i], data[i + 1], 0) + } else if (i + 1 === data.length) { + r += append3bytes(data[i], 0, 0) + } else { + r += append3bytes(data[i], data[i + 1], data[i + 2]) + } + } + return r +} + +function encode6bit(b: number) { + if (b < 10) { + return String.fromCharCode(48 + b) + } + b -= 10 + if (b < 26) { + return String.fromCharCode(65 + b) + } + b -= 26 + if (b < 26) { + return String.fromCharCode(97 + b) + } + b -= 26 + if (b === 0) { + return '-' + } + if (b === 1) { + return '_' + } + return '?' +} + +function append3bytes(b1: number, b2: number, b3: number) { + const c1 = b1 >> 2 + const c2 = ((b1 & 0x3) << 4) | (b2 >> 4) + const c3 = ((b2 & 0xf) << 2) | (b3 >> 6) + const c4 = b3 & 0x3f + let r = '' + r += encode6bit(c1 & 0x3f) + r += encode6bit(c2 & 0x3f) + r += encode6bit(c3 & 0x3f) + r += encode6bit(c4 & 0x3f) + return r +} +/** + * https://plantuml.com/zh/code-javascript-synchronous + * To use PlantUML image generation, a text diagram description have to be : + 1. Encoded in UTF-8 + 2. Compressed using Deflate algorithm + 3. Reencoded in ASCII using a transformation _close_ to base64 + */ +function encodeDiagram(diagram: string): string { + const utf8text = new TextEncoder().encode(diagram) + const compressed = pako.deflateRaw(utf8text) + return encode64(compressed) +} + +type PlantUMLServerImageProps = { + format: 'png' | 'svg' + diagram: string + onClick?: React.MouseEventHandler +} + +const PlantUMLServerImage: React.FC = ({ format, diagram, onClick }) => { + const url = `${PlantUMLServer}/${format}/${encodeDiagram(diagram)}` + const [loading, setLoading] = useState(true) + return ( + + + }> + { + setLoading(false) + }} + onError={(e) => { + setLoading(false) + const target = e.target as HTMLImageElement + target.style.opacity = '0.5' + target.style.filter = 'blur(2px)' + }} + /> + + + ) +} + +const PlantUMLPopupCantaier: React.FC = ({ resolve, diagram }) => { + const [open, setOpen] = useState(true) + const [downloading, setDownloading] = useState({ + png: false, + svg: false + }) + const [activeTab, setActiveTab] = useState('preview') + const { t } = useTranslation() + console.log(`plantuml diagram: ${diagram}`) + const encodedDiagram = encodeDiagram(diagram) + const onOk = () => { + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + const onClose = () => { + resolve({}) + } + const handleDownload = (format: 'svg' | 'png') => { + const timestamp = Date.now() + const url = `${PlantUMLServer}/${format}/${encodedDiagram}` + setDownloading((prev) => ({ ...prev, [format]: true })) + const filename = `plantuml-diagram-${timestamp}.${format}` + downloadUrl(url, filename) + .catch(() => { + window.message.error(t('plantuml.download.failed')) + }) + .finally(() => { + setDownloading((prev) => ({ ...prev, [format]: false })) + }) + } + + function handleCopy() { + navigator.clipboard.writeText(diagram) + window.message.success(t('message.copy.success')) + } + + return ( + + {activeTab === 'source' && ( + + )} + {activeTab === 'preview' && ( + <> + + + + )} + + ]}> + setActiveTab(key)} + items={[ + { + key: 'preview', + label: t('plantuml.tabs.preview'), + children: + }, + { + key: 'source', + label: t('plantuml.tabs.source'), + children: ( +
+                {diagram}
+              
+ ) + } + ]} + /> +
+ ) +} + +class PlantUMLPopupTopView { + static topviewId = 0 + static hide() { + TopView.hide('PlantUMLPopup') + } + static show(diagram: string) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + diagram={diagram} + />, + 'PlantUMLPopup' + ) + }) + } +} +interface PlantUMLProps { + diagram: string +} +export const PlantUML: React.FC = ({ diagram }) => { + // const { t } = useTranslation() + const onPreview = () => { + PlantUMLPopupTopView.show(diagram) + } + return +} + +const StyledPlantUML = styled.div` + max-height: calc(80vh - 100px); + text-align: center; + overflow-y: auto; + img { + max-width: 100%; + height: auto; + min-height: 100px; + background: var(--color-code-background); + cursor: pointer; + } +` +async function downloadUrl(url: string, filename: string) { + const response = await fetch(url) + if (!response.ok) { + window.message.warning({ content: response.statusText, duration: 1.5 }) + return + } + const blob = await response.blob() + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) +} diff --git a/yarn.lock b/yarn.lock index 77d25087..80fcabae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2664,6 +2664,13 @@ __metadata: languageName: node linkType: hard +"@types/pako@npm:^1.0.2": + version: 1.0.7 + resolution: "@types/pako@npm:1.0.7" + checksum: 10c0/1ba133db0b30a974c3d651c85651fd30135f629727b4b4d7ef2649c8f8b01014d5ef41f75399d939e320a50bfa87c32beccbb513badfeaf85d74ea6d5370fdcc + languageName: node + linkType: hard + "@types/plist@npm:^3.0.1": version: 3.0.5 resolution: "@types/plist@npm:3.0.5" @@ -3000,6 +3007,7 @@ __metadata: "@types/markdown-it": "npm:^14" "@types/md5": "npm:^2.3.5" "@types/node": "npm:^18.19.9" + "@types/pako": "npm:^1.0.2" "@types/react": "npm:^18.2.48" "@types/react-dom": "npm:^18.2.18" "@types/react-infinite-scroll-component": "npm:^5.0.0"