From dc43000423b54d6118afd4a12b1f840a7aec7f0b Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 4 Oct 2025 16:16:01 +0200 Subject: [PATCH 1/3] feat: add offline PlantUML rendering option --- App.tsx | 2 +- FUNCTIONAL_MANUAL.md | 11 +- TECHNICAL_MANUAL.md | 2 + components/InfoView.tsx | 9 +- components/PreviewPane.tsx | 9 +- components/PromptEditor.tsx | 2 +- components/SettingsView.tsx | 29 +++- constants.ts | 1 + docs/FUNCTIONAL_MANUAL.md | 11 +- docs/TECHNICAL_MANUAL.md | 2 + electron/main.ts | 72 +++++++++ electron/preload.ts | 1 + package-lock.json | 63 +++++++- package.json | 1 + services/preview/IRenderer.ts | 3 +- services/preview/htmlRenderer.tsx | 3 +- services/preview/imageRenderer.tsx | 9 +- services/preview/markdownRenderer.tsx | 202 ++++++++++++++++++++++--- services/preview/pdfRenderer.tsx | 9 +- services/preview/plaintextRenderer.tsx | 3 +- types.ts | 5 + types/node-plantuml.d.ts | 17 +++ 22 files changed, 427 insertions(+), 39 deletions(-) create mode 100644 types/node-plantuml.d.ts diff --git a/App.tsx b/App.tsx index 8e5587b..86bec6e 100644 --- a/App.tsx +++ b/App.tsx @@ -1090,7 +1090,7 @@ const MainApp: React.FC = () => { } const renderMainContent = () => { - if (view === 'info') return ; + if (view === 'info') return ; if (view === 'settings') return ; if (activeTemplate) { diff --git a/FUNCTIONAL_MANUAL.md b/FUNCTIONAL_MANUAL.md index 1350646..f748b0b 100644 --- a/FUNCTIONAL_MANUAL.md +++ b/FUNCTIONAL_MANUAL.md @@ -133,7 +133,7 @@ Accessed via the gear icon in the title bar. The settings are organized into cat - **LLM Provider:** Configure your connection to a local AI service. You can detect running services and select a model. - **Appearance:** Change the UI scale and choose from different icon sets. - **Keyboard Shortcuts:** View and customize keyboard shortcuts for all major application actions. You can record a new key combination for any command. -- **General:** Configure application behavior, like auto-saving logs and opting into pre-release updates. +- **General:** Configure application behavior, like auto-saving logs, opting into pre-release updates, and choosing how PlantUML diagrams are rendered. - **Database:** View detailed statistics about your local database file, and perform maintenance tasks such as creating a compressed backup, checking file integrity, and optimizing the database size (`VACUUM`). - **Advanced:** View and edit the raw JSON configuration file using an interactive tree or a raw text editor, and import/export your settings. @@ -141,6 +141,15 @@ Accessed via the gear icon in the title bar. The settings are organized into cat Accessed via the info icon in the title bar. This view contains tabs for reading the application's `README.md`, this `FUNCTIONAL_MANUAL.md`, the `TECHNICAL_MANUAL.md`, and the `VERSION_LOG.md`. +#### PlantUML Rendering Modes + +The **General** settings category includes a **PlantUML Rendering** selector. Choose between: + +- **Remote (plantuml.com):** Encodes the diagram and requests the SVG from the public PlantUML server. +- **Offline (local renderer):** Invokes the bundled PlantUML engine inside the desktop application. This mode requires a local Java Runtime Environment and access to Graphviz (or the bundled `viz.js` assets) so the renderer can generate diagrams without contacting plantuml.com. + +If the Java runtime is unavailable, DocForge will report the error in the preview and you can switch back to remote rendering at any time. + ### Logger Panel Accessed via the terminal icon in the title bar, this panel is your primary tool for debugging and monitoring application activity. diff --git a/TECHNICAL_MANUAL.md b/TECHNICAL_MANUAL.md index 7b3c94f..45c7dd4 100644 --- a/TECHNICAL_MANUAL.md +++ b/TECHNICAL_MANUAL.md @@ -14,6 +14,7 @@ This document provides a technical overview of the DocForge application's archit - **Bundler:** [esbuild](https://esbuild.github.io/) for fast and efficient bundling of the application's source code. - **Styling:** [Tailwind CSS](https://tailwindcss.com/) for a utility-first CSS framework. - **Packaging:** [electron-builder](https://www.electron.build/) for creating distributable application packages. +- **Diagram Rendering:** [PlantUML](https://plantuml.com/) via either the public plantuml.com service or the bundled [`node-plantuml`](https://www.npmjs.com/package/node-plantuml) renderer. Offline rendering requires a locally installed Java Runtime Environment and access to Graphviz (or the cached `viz.js` binary) so that diagrams can be rendered without network access. --- @@ -94,6 +95,7 @@ This system provides a consistent and extensible editing experience for all docu - **`PreviewPane.tsx`:** This component is responsible for displaying the rendered output of a document. It debounces content updates for performance and uses the `PreviewService` to get the correct output. - **`services/previewService.ts`:** This service acts as a registry for all available renderer "plugins." It exposes a method, `getRendererForLanguage()`, which finds and returns the appropriate renderer for a given language ID (e.g., 'markdown'). - **Renderer Plugins (`services/preview/`):** Each file format with a preview is supported by a dedicated renderer class that implements the `IRenderer` interface. This makes the system highly extensible: to support a new format, one only needs to create a new renderer class and add it to the `previewService` registry. Currently, renderers for Markdown, HTML, and plaintext (fallback) are implemented. + - The Markdown renderer now integrates an offline PlantUML path. When users select the offline mode, the renderer invokes the main-process `node-plantuml` bridge to generate SVG output locally; otherwise it falls back to the remote plantuml.com service. ### LLM Service (`services/llmService.ts`) diff --git a/components/InfoView.tsx b/components/InfoView.tsx index 22fe847..e5e33b5 100644 --- a/components/InfoView.tsx +++ b/components/InfoView.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import PreviewPane from './PreviewPane'; import Spinner from './Spinner'; import { useLogger } from '../hooks/useLogger'; +import type { Settings } from '../types'; type DocTab = 'Readme' | 'Functional Manual' | 'Technical Manual' | 'Version Log'; @@ -12,7 +13,11 @@ const docFiles: Record = { 'Version Log': 'VERSION_LOG.md', }; -const InfoView: React.FC = () => { +interface InfoViewProps { + settings: Settings; +} + +const InfoView: React.FC = ({ settings }) => { const [activeTab, setActiveTab] = useState('Readme'); const [documents, setDocuments] = useState>({ 'Readme': 'Loading...', @@ -100,7 +105,7 @@ const InfoView: React.FC = () => { Loading documentation... ) : ( - + )} diff --git a/components/PreviewPane.tsx b/components/PreviewPane.tsx index bd6a086..9132254 100644 --- a/components/PreviewPane.tsx +++ b/components/PreviewPane.tsx @@ -2,16 +2,17 @@ import React, { useState, useEffect } from 'react'; import { previewService } from '../services/previewService'; import Spinner from './Spinner'; import { useTheme } from '../hooks/useTheme'; -import type { LogLevel } from '../types'; +import type { LogLevel, Settings } from '../types'; interface PreviewPaneProps { content: string; language: string | null; onScroll?: (event: React.UIEvent) => void; addLog: (level: LogLevel, message: string) => void; + settings: Settings; } -const PreviewPane = React.forwardRef(({ content, language, onScroll, addLog }, ref) => { +const PreviewPane = React.forwardRef(({ content, language, onScroll, addLog, settings }, ref) => { const [renderedOutput, setRenderedOutput] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -28,7 +29,7 @@ const PreviewPane = React.forwardRef(({ conten setError(null); const renderer = previewService.getRendererForLanguage(language); - const result = await renderer.render(content, addLog, language); + const result = await renderer.render(content, addLog, language, settings); clearTimeout(loadingTimer); if (!isCancelled) { @@ -57,7 +58,7 @@ const PreviewPane = React.forwardRef(({ conten isCancelled = true; clearTimeout(debounceTimer); }; - }, [content, language, addLog, ref, onScroll]); + }, [content, language, addLog, ref, onScroll, settings]); return (
diff --git a/components/PromptEditor.tsx b/components/PromptEditor.tsx index 9fe65ae..9f56d73 100644 --- a/components/PromptEditor.tsx +++ b/components/PromptEditor.tsx @@ -482,7 +482,7 @@ const DocumentEditor: React.FC = ({ documentNode, onSave, o fontSize={settings.editorFontSize} /> ); - const preview = ; + const preview = ; switch(viewMode) { case 'edit': return editor; diff --git a/components/SettingsView.tsx b/components/SettingsView.tsx index e414839..e491e10 100644 --- a/components/SettingsView.tsx +++ b/components/SettingsView.tsx @@ -1369,8 +1369,10 @@ requests" }; const GeneralSettingsSection: React.FC> = ({ settings, setCurrentSettings, sectionRef }) => { + const isOfflineRendererAvailable = typeof window !== 'undefined' && !!window.electronAPI?.renderPlantUML; + const offlineRendererMessage = 'Offline rendering requires the desktop application with a local Java runtime.'; return ( -
+

General

@@ -1379,6 +1381,31 @@ const GeneralSettingsSection: React.FC setCurrentSettings(s => ({...s, autoSaveLogs: val}))} /> + +
+ + {!isOfflineRendererAvailable && ( +

+ {offlineRendererMessage} +

+ )} +
+
); diff --git a/constants.ts b/constants.ts index 742748a..ffbc460 100644 --- a/constants.ts +++ b/constants.ts @@ -24,6 +24,7 @@ export const DEFAULT_SETTINGS: Settings = { iconSet: 'heroicons', autoSaveLogs: false, allowPrerelease: false, + plantumlRendererMode: 'remote', uiScale: 100, documentTreeIndent: 16, documentTreeVerticalSpacing: 4, diff --git a/docs/FUNCTIONAL_MANUAL.md b/docs/FUNCTIONAL_MANUAL.md index 1350646..f748b0b 100644 --- a/docs/FUNCTIONAL_MANUAL.md +++ b/docs/FUNCTIONAL_MANUAL.md @@ -133,7 +133,7 @@ Accessed via the gear icon in the title bar. The settings are organized into cat - **LLM Provider:** Configure your connection to a local AI service. You can detect running services and select a model. - **Appearance:** Change the UI scale and choose from different icon sets. - **Keyboard Shortcuts:** View and customize keyboard shortcuts for all major application actions. You can record a new key combination for any command. -- **General:** Configure application behavior, like auto-saving logs and opting into pre-release updates. +- **General:** Configure application behavior, like auto-saving logs, opting into pre-release updates, and choosing how PlantUML diagrams are rendered. - **Database:** View detailed statistics about your local database file, and perform maintenance tasks such as creating a compressed backup, checking file integrity, and optimizing the database size (`VACUUM`). - **Advanced:** View and edit the raw JSON configuration file using an interactive tree or a raw text editor, and import/export your settings. @@ -141,6 +141,15 @@ Accessed via the gear icon in the title bar. The settings are organized into cat Accessed via the info icon in the title bar. This view contains tabs for reading the application's `README.md`, this `FUNCTIONAL_MANUAL.md`, the `TECHNICAL_MANUAL.md`, and the `VERSION_LOG.md`. +#### PlantUML Rendering Modes + +The **General** settings category includes a **PlantUML Rendering** selector. Choose between: + +- **Remote (plantuml.com):** Encodes the diagram and requests the SVG from the public PlantUML server. +- **Offline (local renderer):** Invokes the bundled PlantUML engine inside the desktop application. This mode requires a local Java Runtime Environment and access to Graphviz (or the bundled `viz.js` assets) so the renderer can generate diagrams without contacting plantuml.com. + +If the Java runtime is unavailable, DocForge will report the error in the preview and you can switch back to remote rendering at any time. + ### Logger Panel Accessed via the terminal icon in the title bar, this panel is your primary tool for debugging and monitoring application activity. diff --git a/docs/TECHNICAL_MANUAL.md b/docs/TECHNICAL_MANUAL.md index 33ebe95..1f2e6de 100644 --- a/docs/TECHNICAL_MANUAL.md +++ b/docs/TECHNICAL_MANUAL.md @@ -14,6 +14,7 @@ This document provides a technical overview of the DocForge application's archit - **Bundler:** [esbuild](https://esbuild.github.io/) for fast and efficient bundling of the application's source code. - **Styling:** [Tailwind CSS](https://tailwindcss.com/) for a utility-first CSS framework. - **Packaging:** [electron-builder](https://www.electron.build/) for creating distributable application packages. +- **Diagram Rendering:** [PlantUML](https://plantuml.com/) via either the public plantuml.com service or the bundled [`node-plantuml`](https://www.npmjs.com/package/node-plantuml) renderer. Offline rendering requires a locally installed Java Runtime Environment and access to Graphviz (or the cached `viz.js` binary) so that diagrams can be rendered without network access. --- @@ -94,6 +95,7 @@ This system provides a consistent and extensible editing experience for all docu - **`PreviewPane.tsx`:** This component is responsible for displaying the rendered output of a document. It debounces content updates for performance and uses the `PreviewService` to get the correct output. - **`services/previewService.ts`:** This service acts as a registry for all available renderer "plugins." It exposes a method, `getRendererForLanguage()`, which finds and returns the appropriate renderer for a given language ID (e.g., 'markdown'). - **Renderer Plugins (`services/preview/`):** Each file format with a preview is supported by a dedicated renderer class that implements the `IRenderer` interface. This makes the system highly extensible: to support a new format, one only needs to create a new renderer class and add it to the `previewService` registry. Currently, renderers for Markdown, HTML, and plaintext (fallback) are implemented. + - The Markdown renderer now integrates an offline PlantUML path. When users select the offline mode, the renderer invokes the main-process `node-plantuml` bridge to generate SVG output locally; otherwise it falls back to the remote plantuml.com service. ### LLM Service (`services/llmService.ts`) diff --git a/electron/main.ts b/electron/main.ts index 2245928..a001dfe 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,6 +13,7 @@ import * as zlib from 'zlib'; import * as os from 'os'; import * as stream from 'stream'; import { promisify } from 'util'; +import { generate as plantumlGenerate } from 'node-plantuml'; // Fix: Inform TypeScript about the __dirname global variable provided by Node.js, which is present in a CommonJS-like environment. declare const __dirname: string; @@ -336,6 +337,77 @@ ipcMain.handle('docs:read', async (_, filename: string) => { } }); +ipcMain.handle('plantuml:render-svg', async (_, diagram: string, format: 'svg' = 'svg') => { + const trimmed = (diagram ?? '').trim(); + if (!trimmed) { + return { success: false, error: 'Diagram content is empty.' }; + } + + if (format !== 'svg') { + return { success: false, error: `Unsupported PlantUML format: ${format}` }; + } + + try { + const generator = plantumlGenerate(trimmed, { format: 'svg' }); + generator.out.setEncoding('utf-8'); + generator.err.setEncoding('utf-8'); + + return await new Promise<{ success: boolean; svg?: string; error?: string; details?: string }>((resolve) => { + let svgOutput = ''; + let errorOutput = ''; + + const cleanup = () => { + generator.out.removeAllListeners(); + generator.err.removeAllListeners(); + }; + + const resolveWithError = (message: string) => { + cleanup(); + resolve({ + success: false, + error: message, + details: errorOutput.trim() || undefined, + }); + }; + + generator.err.on('data', (chunk) => { + errorOutput += chunk.toString(); + }); + + generator.out.on('data', (chunk) => { + svgOutput += chunk.toString(); + }); + + generator.out.on('end', () => { + cleanup(); + if (svgOutput.trim()) { + resolve({ success: true, svg: svgOutput }); + } else { + resolve({ + success: false, + error: 'PlantUML renderer produced no SVG output.', + details: errorOutput.trim() || undefined, + }); + } + }); + + generator.out.on('error', (streamError) => { + const message = streamError instanceof Error ? streamError.message : String(streamError); + resolveWithError(message); + }); + + generator.err.on('error', (streamError) => { + const message = streamError instanceof Error ? streamError.message : String(streamError); + resolveWithError(message); + }); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('PlantUML rendering failed:', error); + return { success: false, error: message }; + } +}); + // Python environments & execution ipcMain.handle('python:list-envs', async () => { return pythonManager.listEnvironments(); diff --git a/electron/preload.ts b/electron/preload.ts index 0ad4f25..866c2ad 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -24,6 +24,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getAppVersion: () => ipcRenderer.invoke('app:get-version'), getPlatform: () => ipcRenderer.invoke('app:get-platform'), getLogPath: () => ipcRenderer.invoke('app:get-log-path'), + renderPlantUML: (diagram: string, format: 'svg' = 'svg') => ipcRenderer.invoke('plantuml:render-svg', diagram, format), updaterSetAllowPrerelease: (allow: boolean) => ipcRenderer.send('updater:set-allow-prerelease', allow), onUpdateDownloaded: (callback: (version: string) => void) => { const handler = (_: IpcRendererEvent, version: string) => callback(version); diff --git a/package-lock.json b/package-lock.json index 124ee73..c90bd6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "electron-log": "^5.1.5", "electron-squirrel-startup": "^1.0.1", "electron-updater": "^6.2.1", + "node-plantuml": "^0.9.0", "uuid": "^10.0.0" }, "devDependencies": { @@ -7940,6 +7941,67 @@ "license": "MIT", "optional": true }, + "node_modules/node-nailgun-client": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/node-nailgun-client/-/node-nailgun-client-0.1.2.tgz", + "integrity": "sha512-OC611lR0fsDUSptwnhBf8d3sj4DZ5fiRKfS2QaGPe0kR3Dt9YoZr1MY7utK0scFPTbXuQdSBBbeoKYVbME1q5g==", + "license": "Apache v2", + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "node-nailgun-client": "index.js" + } + }, + "node_modules/node-nailgun-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/node-nailgun-server": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/node-nailgun-server/-/node-nailgun-server-0.1.4.tgz", + "integrity": "sha512-e0Hbh6XPb/7GqATJ45BePaUEO5AwR7InRW/pGeMKHH1cqPMBFCeqdBNfvi+bkVLnsbYOOQE+pAek9nmNoD8sYw==", + "license": "Apache-2.0", + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "node-nailgun-server": "index.js" + } + }, + "node_modules/node-nailgun-server/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/node-plantuml": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/node-plantuml/-/node-plantuml-0.9.0.tgz", + "integrity": "sha512-bUnntTGjbpYu1pvXZI/GS6ctcXf3AOMqJxBMO8vFzTT5RwH8Cj/J5Ca6Dy+PEfMiMDdSBCFKSGnvYyBvYnucXg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "commander": "^2.8.1", + "node-nailgun-client": "^0.1.0", + "node-nailgun-server": "^0.1.4", + "plantuml-encoder": "^1.2.5" + }, + "bin": { + "puml": "index.js" + }, + "engines": { + "node": ">= 6.x" + } + }, + "node_modules/node-plantuml/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.21", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", @@ -8234,7 +8296,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/plantuml-encoder/-/plantuml-encoder-1.4.0.tgz", "integrity": "sha512-sxMwpDw/ySY1WB2CE3+IdMuEcWibJ72DDOsXLkSmEaSzwEUaYBT6DWgOfBiHGCux4q433X6+OEFWjlVqp7gL6g==", - "dev": true, "license": "MIT" }, "node_modules/plist": { diff --git a/package.json b/package.json index cbc6866..286d0bd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "electron-log": "^5.1.5", "electron-squirrel-startup": "^1.0.1", "electron-updater": "^6.2.1", + "node-plantuml": "^0.9.0", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/services/preview/IRenderer.ts b/services/preview/IRenderer.ts index 484d7c6..e868c35 100644 --- a/services/preview/IRenderer.ts +++ b/services/preview/IRenderer.ts @@ -1,5 +1,5 @@ import type React from 'react'; -import type { LogLevel } from '../../types'; +import type { LogLevel, Settings } from '../../types'; export interface IRenderer { /** @@ -14,5 +14,6 @@ export interface IRenderer { content: string, addLog?: (level: LogLevel, message: string) => void, languageId?: string | null, + settings?: Settings, ): Promise<{ output: React.ReactElement | string; error?: string }>; } diff --git a/services/preview/htmlRenderer.tsx b/services/preview/htmlRenderer.tsx index 2991d9a..1fe7bfd 100644 --- a/services/preview/htmlRenderer.tsx +++ b/services/preview/htmlRenderer.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { IRenderer } from './IRenderer'; -import type { LogLevel } from '../../types'; +import type { LogLevel, Settings } from '../../types'; export class HtmlRenderer implements IRenderer { canRender(languageId: string): boolean { @@ -11,6 +11,7 @@ export class HtmlRenderer implements IRenderer { content: string, addLog?: (level: LogLevel, message: string) => void, languageId?: string | null, + _settings?: Settings, ): Promise<{ output: React.ReactElement; error?: string }> { // Using `color-scheme` allows the iframe content to respect the system's light/dark mode preference. const fullHtml = `${content}`; diff --git a/services/preview/imageRenderer.tsx b/services/preview/imageRenderer.tsx index f5e0d1a..48bd0af 100644 --- a/services/preview/imageRenderer.tsx +++ b/services/preview/imageRenderer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { IRenderer } from './IRenderer'; -import type { LogLevel } from '../../types'; +import type { LogLevel, Settings } from '../../types'; type SupportedImageType = | 'image/png' @@ -299,7 +299,12 @@ export class ImageRenderer implements IRenderer { return this.supportedIds.includes(normalized); } - async render(content: string, addLog?: (level: LogLevel, message: string) => void, languageId?: string | null) { + async render( + content: string, + addLog?: (level: LogLevel, message: string) => void, + languageId?: string | null, + _settings?: Settings, + ) { return { output: }; } } diff --git a/services/preview/markdownRenderer.tsx b/services/preview/markdownRenderer.tsx index cedc2d4..3cc6dc2 100644 --- a/services/preview/markdownRenderer.tsx +++ b/services/preview/markdownRenderer.tsx @@ -10,7 +10,8 @@ import type { Highlighter } from 'shiki'; import mermaid from 'mermaid'; import plantumlEncoder from 'plantuml-encoder'; import type { IRenderer } from './IRenderer'; -import type { LogLevel } from '../../types'; +import type { LogLevel, Settings } from '../../types'; +import { DEFAULT_SETTINGS } from '../../constants'; import { useTheme } from '../../hooks/useTheme'; import { getSharedHighlighter } from './shikiHighlighter'; @@ -18,6 +19,7 @@ import 'katex/dist/katex.min.css'; interface MarkdownViewerProps { content: string; + settings: Settings; onScroll?: (event: React.UIEvent) => void; } @@ -101,28 +103,71 @@ const MermaidDiagram: React.FC = ({ code, theme }) => { interface PlantUMLDiagramProps { code: string; + mode: Settings['plantumlRendererMode']; } const PLANTUML_LANGS = ['plantuml', 'puml', 'uml']; const PLANTUML_SERVER = 'https://www.plantuml.com/plantuml/svg'; -const PlantUMLDiagram: React.FC = ({ code }) => { - const [hasError, setHasError] = useState(false); +interface PlantUMLErrorProps { + message: string; + details?: string | null; +} - const encoded = useMemo(() => { +const PlantUMLError: React.FC = ({ message, details }) => ( +
+
+
{message}
+ {details && details.trim() && ( +
+ Technical details + {details} +
+ )} +
+
+); + +const PlantUMLRemoteDiagram: React.FC<{ code: string }> = ({ code }) => { + const { encoded, reason, error } = useMemo(() => { + const trimmed = code.trim(); + if (!trimmed) { + return { encoded: null, reason: 'empty' as const, error: 'The PlantUML code block is empty.' }; + } try { - return plantumlEncoder.encode(code.trim()); + return { encoded: plantumlEncoder.encode(trimmed), reason: 'ok' as const, error: null }; } catch (err) { - return null; + return { + encoded: null, + reason: 'encode-error' as const, + error: err instanceof Error ? err.message : String(err), + }; } }, [code]); + const [hasError, setHasError] = useState(false); + const [errorDetails, setErrorDetails] = useState(error); + + useEffect(() => { + setHasError(false); + setErrorDetails(error); + }, [error, encoded, code]); + if (!encoded) { - return
Unable to encode PlantUML diagram.
; + const message = + reason === 'empty' + ? 'PlantUML diagram is empty.' + : 'Unable to encode PlantUML diagram.'; + return ; } if (hasError) { - return
Failed to load PlantUML diagram from server.
; + return ( + + ); } return ( @@ -131,13 +176,121 @@ const PlantUMLDiagram: React.FC = ({ code }) => { src={`${PLANTUML_SERVER}/${encoded}`} alt="PlantUML diagram" loading="lazy" - onError={() => setHasError(true)} + onError={() => { + setHasError(true); + setErrorDetails(`Request URL: ${PLANTUML_SERVER}/${encoded}`); + }} />
); }; -const MarkdownViewer = forwardRef(({ content, onScroll }, ref) => { +interface OfflineRenderState { + status: 'idle' | 'loading' | 'success' | 'error'; + svg?: string; + error?: string; + details?: string | null; +} + +const PlantUMLOfflineDiagram: React.FC<{ code: string }> = ({ code }) => { + const [state, setState] = useState({ status: 'idle' }); + + useEffect(() => { + let cancelled = false; + const trimmed = code.trim(); + + if (!trimmed) { + setState({ status: 'error', error: 'PlantUML diagram is empty.', details: null }); + return () => { + cancelled = true; + }; + } + + if (typeof window === 'undefined' || !window.electronAPI?.renderPlantUML) { + setState({ + status: 'error', + error: 'Local PlantUML renderer is not available in this environment.', + details: 'Switch to remote rendering or run the desktop app with a Java runtime installed.', + }); + return () => { + cancelled = true; + }; + } + + setState({ status: 'loading' }); + + window.electronAPI + .renderPlantUML(trimmed, 'svg') + .then((result) => { + if (cancelled) { + return; + } + if (result?.success && result.svg) { + setState({ status: 'success', svg: result.svg }); + } else { + setState({ + status: 'error', + error: result?.error || 'The local PlantUML renderer returned no output.', + details: result?.details ?? null, + }); + } + }) + .catch((err) => { + if (cancelled) { + return; + } + const details = err instanceof Error ? err.message : String(err); + setState({ + status: 'error', + error: 'Unable to render PlantUML diagram locally.', + details, + }); + }); + + return () => { + cancelled = true; + }; + }, [code]); + + if (state.status === 'loading' || state.status === 'idle') { + return ( +
+
Rendering diagram locally...
+
+ ); + } + + if (state.status === 'error') { + return ; + } + + if (state.status === 'success' && state.svg) { + return ( +
+ ); + } + + return ( + + ); +}; + +const PlantUMLDiagram: React.FC = ({ code, mode }) => { + if (mode === 'offline') { + return ; + } + return ; +}; + +const MarkdownViewer = forwardRef(({ content, settings, onScroll }, ref) => { const { theme } = useTheme(); const viewTheme: 'light' | 'dark' = theme === 'dark' ? 'dark' : 'light'; const [highlighter, setHighlighter] = useState(null); @@ -200,7 +353,7 @@ const MarkdownViewer = forwardRef(({ conten const raw = React.Children.toArray(codeChild.props.children) .map((child) => (typeof child === 'string' ? child : '')) .join(''); - return ; + return ; } const baseClassName = ['df-code-block', className].filter(Boolean).join(' '); @@ -627,7 +780,8 @@ const MarkdownViewer = forwardRef(({ conten } .df-mermaid svg, - .df-plantuml img { + .df-plantuml img, + .df-plantuml svg { width: 100%; height: auto; } @@ -637,9 +791,6 @@ const MarkdownViewer = forwardRef(({ conten margin-top: 0.75rem; font-size: 0.9rem; color: rgb(var(--color-destructive-text)); - } - - .df-mermaid-error { display: flex; flex-direction: column; gap: 0.5rem; @@ -647,11 +798,13 @@ const MarkdownViewer = forwardRef(({ conten text-align: left; } - .df-mermaid-error__message { + .df-mermaid-error__message, + .df-plantuml-error__message { font-weight: 600; } - .df-mermaid-error__details { + .df-mermaid-error__details, + .df-plantuml-error__details { width: 100%; border: 1px solid rgba(var(--color-border), 0.6); border-radius: 0.5rem; @@ -660,13 +813,15 @@ const MarkdownViewer = forwardRef(({ conten color: rgba(var(--color-text-secondary), 0.95); } - .df-mermaid-error__details > summary { + .df-mermaid-error__details > summary, + .df-plantuml-error__details > summary { cursor: pointer; font-weight: 600; color: rgb(var(--color-destructive-text)); } - .df-mermaid-error__details code { + .df-mermaid-error__details code, + .df-plantuml-error__details code { display: block; margin-top: 0.5rem; word-break: break-word; @@ -674,6 +829,11 @@ const MarkdownViewer = forwardRef(({ conten color: rgba(var(--color-text), 0.95); } + .df-plantuml-loading { + font-size: 0.9rem; + color: rgba(var(--color-text-secondary), 0.9); + } + .df-markdown .katex { font-size: calc(var(--markdown-font-size, 16px) * 1.05); } @@ -695,9 +855,11 @@ export class MarkdownRenderer implements IRenderer { content: string, addLog?: (level: LogLevel, message: string) => void, languageId?: string | null, + settings?: Settings, ): Promise<{ output: React.ReactElement; error?: string }> { try { - return { output: }; + const effectiveSettings = settings ?? DEFAULT_SETTINGS; + return { output: }; } catch (e) { const error = e instanceof Error ? e.message : 'Failed to render Markdown'; addLog?.('ERROR', `[MarkdownRenderer] Render failed: ${error}`); diff --git a/services/preview/pdfRenderer.tsx b/services/preview/pdfRenderer.tsx index 6aff9de..0bcc53c 100644 --- a/services/preview/pdfRenderer.tsx +++ b/services/preview/pdfRenderer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo } from 'react'; import type { IRenderer } from './IRenderer'; -import type { LogLevel } from '../../types'; +import type { LogLevel, Settings } from '../../types'; interface PdfPreviewProps extends React.HTMLAttributes { content: string; @@ -101,7 +101,12 @@ export class PdfRenderer implements IRenderer { return languageId === 'pdf' || languageId === 'application/pdf'; } - async render(content: string, addLog?: (level: LogLevel, message: string) => void, languageId?: string | null) { + async render( + content: string, + addLog?: (level: LogLevel, message: string) => void, + languageId?: string | null, + _settings?: Settings, + ) { return { output: }; } } diff --git a/services/preview/plaintextRenderer.tsx b/services/preview/plaintextRenderer.tsx index 188dab2..428a5d6 100644 --- a/services/preview/plaintextRenderer.tsx +++ b/services/preview/plaintextRenderer.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { IRenderer } from './IRenderer'; -import type { LogLevel } from '../../types'; +import type { LogLevel, Settings } from '../../types'; export class PlaintextRenderer implements IRenderer { canRender(languageId: string): boolean { @@ -12,6 +12,7 @@ export class PlaintextRenderer implements IRenderer { content: string, addLog?: (level: LogLevel, message: string) => void, languageId?: string | null, + _settings?: Settings, ): Promise<{ output: React.ReactElement; error?: string }> { const output =
{content}
; return { output }; diff --git a/types.ts b/types.ts index 6b11dda..b8cf7a1 100644 --- a/types.ts +++ b/types.ts @@ -27,6 +27,10 @@ declare global { getAppVersion: () => Promise; getPlatform: () => Promise; getLogPath: () => Promise; + renderPlantUML: ( + diagram: string, + format?: 'svg' + ) => Promise<{ success: boolean; svg?: string; error?: string; details?: string }>; updaterSetAllowPrerelease: (allow: boolean) => void; onUpdateDownloaded: (callback: (version: string) => void) => () => void; quitAndInstallUpdate: () => void; @@ -259,6 +263,7 @@ export interface Settings { iconSet: 'heroicons' | 'lucide' | 'feather' | 'tabler' | 'material'; autoSaveLogs: boolean; allowPrerelease: boolean; + plantumlRendererMode: 'remote' | 'offline'; uiScale: number; documentTreeIndent: number; documentTreeVerticalSpacing: number; diff --git a/types/node-plantuml.d.ts b/types/node-plantuml.d.ts new file mode 100644 index 0000000..b81a024 --- /dev/null +++ b/types/node-plantuml.d.ts @@ -0,0 +1,17 @@ +declare module 'node-plantuml' { + import type { Readable, Writable } from 'stream'; + + export interface GenerateOptions { + format?: 'png' | 'svg' | 'txt'; + charset?: string; + preserveLineBreaks?: boolean; + } + + export interface GenerationResult { + in: Writable; + out: Readable; + err: Readable; + } + + export function generate(diagramText: string, options?: GenerateOptions): GenerationResult; +} From 0ef52f574bcb1f96398df97229b4cf80f3d9ce39 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 4 Oct 2025 16:29:03 +0200 Subject: [PATCH 2/3] Add standalone PlantUML preview support --- FUNCTIONAL_MANUAL.md | 2 + TECHNICAL_MANUAL.md | 4 +- components/PromptEditor.tsx | 6 + docs/FUNCTIONAL_MANUAL.md | 2 + docs/TECHNICAL_MANUAL.md | 4 +- esbuild.config.js | 2 +- services/languageService.ts | 4 + services/preview/markdownRenderer.tsx | 191 +------------------------ services/preview/plantumlDiagram.tsx | 193 ++++++++++++++++++++++++++ services/preview/plantumlRenderer.tsx | 93 +++++++++++++ services/previewService.ts | 2 + 11 files changed, 308 insertions(+), 195 deletions(-) create mode 100644 services/preview/plantumlDiagram.tsx create mode 100644 services/preview/plantumlRenderer.tsx diff --git a/FUNCTIONAL_MANUAL.md b/FUNCTIONAL_MANUAL.md index f748b0b..e3f4b30 100644 --- a/FUNCTIONAL_MANUAL.md +++ b/FUNCTIONAL_MANUAL.md @@ -150,6 +150,8 @@ The **General** settings category includes a **PlantUML Rendering** selector. Ch If the Java runtime is unavailable, DocForge will report the error in the preview and you can switch back to remote rendering at any time. +The chosen rendering mode is used for PlantUML code blocks inside Markdown documents *and* for standalone `.puml` documents rendered through the dedicated PlantUML previewer. + ### Logger Panel Accessed via the terminal icon in the title bar, this panel is your primary tool for debugging and monitoring application activity. diff --git a/TECHNICAL_MANUAL.md b/TECHNICAL_MANUAL.md index 45c7dd4..c30645f 100644 --- a/TECHNICAL_MANUAL.md +++ b/TECHNICAL_MANUAL.md @@ -94,8 +94,8 @@ This system provides a consistent and extensible editing experience for all docu - **`CodeEditor.tsx`:** A React component that wraps and configures the Monaco Editor instance. It's responsible for managing the editor's content, theme, and language for syntax highlighting based on props. - **`PreviewPane.tsx`:** This component is responsible for displaying the rendered output of a document. It debounces content updates for performance and uses the `PreviewService` to get the correct output. - **`services/previewService.ts`:** This service acts as a registry for all available renderer "plugins." It exposes a method, `getRendererForLanguage()`, which finds and returns the appropriate renderer for a given language ID (e.g., 'markdown'). -- **Renderer Plugins (`services/preview/`):** Each file format with a preview is supported by a dedicated renderer class that implements the `IRenderer` interface. This makes the system highly extensible: to support a new format, one only needs to create a new renderer class and add it to the `previewService` registry. Currently, renderers for Markdown, HTML, and plaintext (fallback) are implemented. - - The Markdown renderer now integrates an offline PlantUML path. When users select the offline mode, the renderer invokes the main-process `node-plantuml` bridge to generate SVG output locally; otherwise it falls back to the remote plantuml.com service. +- **Renderer Plugins (`services/preview/`):** Each file format with a preview is supported by a dedicated renderer class that implements the `IRenderer` interface. This makes the system highly extensible: to support a new format, one only needs to create a new renderer class and add it to the `previewService` registry. The bundled plugins cover Markdown (with Mermaid + PlantUML support), standalone PlantUML documents, HTML, PDFs, common image formats, and a plaintext fallback renderer. + - Both the Markdown renderer and the standalone PlantUML renderer share the `PlantUMLDiagram` component, which routes diagrams through either the remote plantuml.com server or the offline `node-plantuml` IPC bridge depending on the active setting. ### LLM Service (`services/llmService.ts`) diff --git a/components/PromptEditor.tsx b/components/PromptEditor.tsx index 9f56d73..56ab4e3 100644 --- a/components/PromptEditor.tsx +++ b/components/PromptEditor.tsx @@ -29,6 +29,9 @@ interface DocumentEditorProps { const PREVIEWABLE_LANGUAGES = new Set([ 'markdown', 'html', + 'plantuml', + 'puml', + 'uml', 'pdf', 'application/pdf', 'image', @@ -62,6 +65,9 @@ const resolveDefaultViewMode = (mode: ViewMode | null | undefined, languageHint: if (normalizedHint === 'image' || normalizedHint.startsWith('image/')) { return 'preview'; } + if (normalizedHint === 'plantuml' || normalizedHint === 'puml' || normalizedHint === 'uml') { + return 'preview'; + } return 'edit'; }; diff --git a/docs/FUNCTIONAL_MANUAL.md b/docs/FUNCTIONAL_MANUAL.md index f748b0b..e3f4b30 100644 --- a/docs/FUNCTIONAL_MANUAL.md +++ b/docs/FUNCTIONAL_MANUAL.md @@ -150,6 +150,8 @@ The **General** settings category includes a **PlantUML Rendering** selector. Ch If the Java runtime is unavailable, DocForge will report the error in the preview and you can switch back to remote rendering at any time. +The chosen rendering mode is used for PlantUML code blocks inside Markdown documents *and* for standalone `.puml` documents rendered through the dedicated PlantUML previewer. + ### Logger Panel Accessed via the terminal icon in the title bar, this panel is your primary tool for debugging and monitoring application activity. diff --git a/docs/TECHNICAL_MANUAL.md b/docs/TECHNICAL_MANUAL.md index 1f2e6de..9e3eb8d 100644 --- a/docs/TECHNICAL_MANUAL.md +++ b/docs/TECHNICAL_MANUAL.md @@ -94,8 +94,8 @@ This system provides a consistent and extensible editing experience for all docu - **`CodeEditor.tsx`:** A React component that wraps and configures the Monaco Editor instance. It's responsible for managing the editor's content, theme, and language for syntax highlighting based on props. - **`PreviewPane.tsx`:** This component is responsible for displaying the rendered output of a document. It debounces content updates for performance and uses the `PreviewService` to get the correct output. - **`services/previewService.ts`:** This service acts as a registry for all available renderer "plugins." It exposes a method, `getRendererForLanguage()`, which finds and returns the appropriate renderer for a given language ID (e.g., 'markdown'). -- **Renderer Plugins (`services/preview/`):** Each file format with a preview is supported by a dedicated renderer class that implements the `IRenderer` interface. This makes the system highly extensible: to support a new format, one only needs to create a new renderer class and add it to the `previewService` registry. Currently, renderers for Markdown, HTML, and plaintext (fallback) are implemented. - - The Markdown renderer now integrates an offline PlantUML path. When users select the offline mode, the renderer invokes the main-process `node-plantuml` bridge to generate SVG output locally; otherwise it falls back to the remote plantuml.com service. +- **Renderer Plugins (`services/preview/`):** Each file format with a preview is supported by a dedicated renderer class that implements the `IRenderer` interface. This makes the system highly extensible: to support a new format, one only needs to create a new renderer class and add it to the `previewService` registry. The bundled plugins cover Markdown (with Mermaid + PlantUML support), standalone PlantUML documents, HTML, PDFs, common image formats, and a plaintext fallback renderer. + - Both the Markdown renderer and the standalone PlantUML renderer share the `PlantUMLDiagram` component, which routes diagrams through either the remote plantuml.com server or the offline `node-plantuml` IPC bridge depending on the active setting. ### LLM Service (`services/llmService.ts`) diff --git a/esbuild.config.js b/esbuild.config.js index 059c8ab..cbd0c5b 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -34,7 +34,7 @@ const buildOrWatch = async (name, config) => { platform: 'node', entryPoints: ['electron/main.ts'], outfile: 'dist/main.js', - external: ['electron', 'better-sqlite3'], + external: ['electron', 'better-sqlite3', 'node-plantuml'], }), buildOrWatch('preload', { ...sharedConfig, diff --git a/services/languageService.ts b/services/languageService.ts index 58feadf..ff73dd2 100644 --- a/services/languageService.ts +++ b/services/languageService.ts @@ -9,6 +9,7 @@ export const SUPPORTED_LANGUAGES = [ { id: 'css', label: 'CSS' }, { id: 'json', label: 'JSON' }, { id: 'markdown', label: 'Markdown' }, + { id: 'plantuml', label: 'PlantUML' }, { id: 'java', label: 'Java' }, { id: 'csharp', label: 'C#' }, { id: 'cpp', label: 'C++' }, @@ -46,6 +47,9 @@ export const mapExtensionToLanguageId = (extension: string | null): string => { case 'md': case 'markdown': return 'markdown'; + case 'puml': + case 'plantuml': + return 'plantuml'; case 'java': return 'java'; case 'cs': diff --git a/services/preview/markdownRenderer.tsx b/services/preview/markdownRenderer.tsx index 3cc6dc2..00ed184 100644 --- a/services/preview/markdownRenderer.tsx +++ b/services/preview/markdownRenderer.tsx @@ -8,12 +8,12 @@ import rehypeKatex from 'rehype-katex'; import type { Components } from 'react-markdown'; import type { Highlighter } from 'shiki'; import mermaid from 'mermaid'; -import plantumlEncoder from 'plantuml-encoder'; import type { IRenderer } from './IRenderer'; import type { LogLevel, Settings } from '../../types'; import { DEFAULT_SETTINGS } from '../../constants'; import { useTheme } from '../../hooks/useTheme'; import { getSharedHighlighter } from './shikiHighlighter'; +import { PlantUMLDiagram, PLANTUML_LANGS } from './plantumlDiagram'; import 'katex/dist/katex.min.css'; @@ -101,195 +101,6 @@ const MermaidDiagram: React.FC = ({ code, theme }) => { ); }; -interface PlantUMLDiagramProps { - code: string; - mode: Settings['plantumlRendererMode']; -} - -const PLANTUML_LANGS = ['plantuml', 'puml', 'uml']; -const PLANTUML_SERVER = 'https://www.plantuml.com/plantuml/svg'; - -interface PlantUMLErrorProps { - message: string; - details?: string | null; -} - -const PlantUMLError: React.FC = ({ message, details }) => ( -
-
-
{message}
- {details && details.trim() && ( -
- Technical details - {details} -
- )} -
-
-); - -const PlantUMLRemoteDiagram: React.FC<{ code: string }> = ({ code }) => { - const { encoded, reason, error } = useMemo(() => { - const trimmed = code.trim(); - if (!trimmed) { - return { encoded: null, reason: 'empty' as const, error: 'The PlantUML code block is empty.' }; - } - try { - return { encoded: plantumlEncoder.encode(trimmed), reason: 'ok' as const, error: null }; - } catch (err) { - return { - encoded: null, - reason: 'encode-error' as const, - error: err instanceof Error ? err.message : String(err), - }; - } - }, [code]); - - const [hasError, setHasError] = useState(false); - const [errorDetails, setErrorDetails] = useState(error); - - useEffect(() => { - setHasError(false); - setErrorDetails(error); - }, [error, encoded, code]); - - if (!encoded) { - const message = - reason === 'empty' - ? 'PlantUML diagram is empty.' - : 'Unable to encode PlantUML diagram.'; - return ; - } - - if (hasError) { - return ( - - ); - } - - return ( -
- PlantUML diagram { - setHasError(true); - setErrorDetails(`Request URL: ${PLANTUML_SERVER}/${encoded}`); - }} - /> -
- ); -}; - -interface OfflineRenderState { - status: 'idle' | 'loading' | 'success' | 'error'; - svg?: string; - error?: string; - details?: string | null; -} - -const PlantUMLOfflineDiagram: React.FC<{ code: string }> = ({ code }) => { - const [state, setState] = useState({ status: 'idle' }); - - useEffect(() => { - let cancelled = false; - const trimmed = code.trim(); - - if (!trimmed) { - setState({ status: 'error', error: 'PlantUML diagram is empty.', details: null }); - return () => { - cancelled = true; - }; - } - - if (typeof window === 'undefined' || !window.electronAPI?.renderPlantUML) { - setState({ - status: 'error', - error: 'Local PlantUML renderer is not available in this environment.', - details: 'Switch to remote rendering or run the desktop app with a Java runtime installed.', - }); - return () => { - cancelled = true; - }; - } - - setState({ status: 'loading' }); - - window.electronAPI - .renderPlantUML(trimmed, 'svg') - .then((result) => { - if (cancelled) { - return; - } - if (result?.success && result.svg) { - setState({ status: 'success', svg: result.svg }); - } else { - setState({ - status: 'error', - error: result?.error || 'The local PlantUML renderer returned no output.', - details: result?.details ?? null, - }); - } - }) - .catch((err) => { - if (cancelled) { - return; - } - const details = err instanceof Error ? err.message : String(err); - setState({ - status: 'error', - error: 'Unable to render PlantUML diagram locally.', - details, - }); - }); - - return () => { - cancelled = true; - }; - }, [code]); - - if (state.status === 'loading' || state.status === 'idle') { - return ( -
-
Rendering diagram locally...
-
- ); - } - - if (state.status === 'error') { - return ; - } - - if (state.status === 'success' && state.svg) { - return ( -
- ); - } - - return ( - - ); -}; - -const PlantUMLDiagram: React.FC = ({ code, mode }) => { - if (mode === 'offline') { - return ; - } - return ; -}; - const MarkdownViewer = forwardRef(({ content, settings, onScroll }, ref) => { const { theme } = useTheme(); const viewTheme: 'light' | 'dark' = theme === 'dark' ? 'dark' : 'light'; diff --git a/services/preview/plantumlDiagram.tsx b/services/preview/plantumlDiagram.tsx new file mode 100644 index 0000000..6d65d2f --- /dev/null +++ b/services/preview/plantumlDiagram.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import plantumlEncoder from 'plantuml-encoder'; +import type { Settings } from '../../types'; + +export const PLANTUML_LANGS = ['plantuml', 'puml', 'uml'] as const; +const PLANTUML_SERVER = 'https://www.plantuml.com/plantuml/svg'; + +interface PlantUMLDiagramProps { + code: string; + mode: Settings['plantumlRendererMode']; +} + +interface PlantUMLErrorProps { + message: string; + details?: string | null; +} + +const PlantUMLError: React.FC = ({ message, details }) => ( +
+
+
{message}
+ {details && details.trim() && ( +
+ Technical details + {details} +
+ )} +
+
+); + +const PlantUMLRemoteDiagram: React.FC<{ code: string }> = ({ code }) => { + const { encoded, reason, error } = useMemo(() => { + const trimmed = code.trim(); + if (!trimmed) { + return { encoded: null, reason: 'empty' as const, error: 'The PlantUML code block is empty.' }; + } + try { + return { encoded: plantumlEncoder.encode(trimmed), reason: 'ok' as const, error: null }; + } catch (err) { + return { + encoded: null, + reason: 'encode-error' as const, + error: err instanceof Error ? err.message : String(err), + }; + } + }, [code]); + + const [hasError, setHasError] = useState(false); + const [errorDetails, setErrorDetails] = useState(error); + + useEffect(() => { + setHasError(false); + setErrorDetails(error); + }, [error, encoded, code]); + + if (!encoded) { + const message = + reason === 'empty' + ? 'PlantUML diagram is empty.' + : 'Unable to encode PlantUML diagram.'; + return ; + } + + if (hasError) { + return ( + + ); + } + + return ( +
+ PlantUML diagram { + setHasError(true); + setErrorDetails(`Request URL: ${PLANTUML_SERVER}/${encoded}`); + }} + /> +
+ ); +}; + +interface OfflineRenderState { + status: 'idle' | 'loading' | 'success' | 'error'; + svg?: string; + error?: string; + details?: string | null; +} + +const PlantUMLOfflineDiagram: React.FC<{ code: string }> = ({ code }) => { + const [state, setState] = useState({ status: 'idle' }); + + useEffect(() => { + let cancelled = false; + const trimmed = code.trim(); + + if (!trimmed) { + setState({ status: 'error', error: 'PlantUML diagram is empty.', details: null }); + return () => { + cancelled = true; + }; + } + + if (typeof window === 'undefined' || !window.electronAPI?.renderPlantUML) { + setState({ + status: 'error', + error: 'Local PlantUML renderer is not available in this environment.', + details: 'Switch to remote rendering or run the desktop app with a Java runtime installed.', + }); + return () => { + cancelled = true; + }; + } + + setState({ status: 'loading' }); + + window.electronAPI + .renderPlantUML(trimmed, 'svg') + .then((result) => { + if (cancelled) { + return; + } + if (result?.success && result.svg) { + setState({ status: 'success', svg: result.svg }); + } else { + setState({ + status: 'error', + error: result?.error || 'The local PlantUML renderer returned no output.', + details: result?.details ?? null, + }); + } + }) + .catch((err) => { + if (cancelled) { + return; + } + const details = err instanceof Error ? err.message : String(err); + setState({ + status: 'error', + error: 'Unable to render PlantUML diagram locally.', + details, + }); + }); + + return () => { + cancelled = true; + }; + }, [code]); + + if (state.status === 'loading' || state.status === 'idle') { + return ( +
+
Rendering diagram locally...
+
+ ); + } + + if (state.status === 'error') { + return ; + } + + if (state.status === 'success' && state.svg) { + return ( +
+ ); + } + + return ( + + ); +}; + +export const PlantUMLDiagram: React.FC = ({ code, mode }) => { + if (mode === 'offline') { + return ; + } + return ; +}; + diff --git a/services/preview/plantumlRenderer.tsx b/services/preview/plantumlRenderer.tsx new file mode 100644 index 0000000..a929e98 --- /dev/null +++ b/services/preview/plantumlRenderer.tsx @@ -0,0 +1,93 @@ +import React, { useMemo } from 'react'; +import type { IRenderer } from './IRenderer'; +import type { LogLevel, Settings } from '../../types'; +import { DEFAULT_SETTINGS } from '../../constants'; +import { PlantUMLDiagram } from './plantumlDiagram'; + +interface PlantUMLPreviewProps { + content: string; + settings: Settings; +} + +const PlantUMLPreview: React.FC = ({ content, settings }) => { + const trimmed = useMemo(() => content.trim(), [content]); + + return ( +
+ + +
+ ); +}; + +export class PlantUMLRenderer implements IRenderer { + canRender(languageId: string): boolean { + const normalized = languageId.toLowerCase(); + return normalized === 'plantuml' || normalized === 'puml' || normalized === 'uml'; + } + + async render( + content: string, + addLog?: (level: LogLevel, message: string) => void, + _languageId?: string | null, + settings?: Settings, + ): Promise<{ output: React.ReactElement; error?: string }> { + const effectiveSettings = settings ?? DEFAULT_SETTINGS; + + if (!content.trim()) { + addLog?.('WARN', 'PlantUML document has no content to render.'); + } + + return { + output: , + }; + } +} + diff --git a/services/previewService.ts b/services/previewService.ts index 710cadb..a00675a 100644 --- a/services/previewService.ts +++ b/services/previewService.ts @@ -4,6 +4,7 @@ import { MarkdownRenderer } from './preview/markdownRenderer'; import { PlaintextRenderer } from './preview/plaintextRenderer'; import { PdfRenderer } from './preview/pdfRenderer'; import { ImageRenderer } from './preview/imageRenderer'; +import { PlantUMLRenderer } from './preview/plantumlRenderer'; class PreviewService { private renderers: IRenderer[]; @@ -12,6 +13,7 @@ class PreviewService { // The order is important: more specific renderers should come before the generic fallback. this.renderers = [ new MarkdownRenderer(), + new PlantUMLRenderer(), new HtmlRenderer(), new PdfRenderer(), new ImageRenderer(), From 703e0abe6179337d42f2612840ce9c9f412da4be Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sat, 4 Oct 2025 16:33:58 +0200 Subject: [PATCH 3/3] Fix PlantUML renderer mode memo dependency --- services/preview/markdownRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/preview/markdownRenderer.tsx b/services/preview/markdownRenderer.tsx index 00ed184..cc0afaa 100644 --- a/services/preview/markdownRenderer.tsx +++ b/services/preview/markdownRenderer.tsx @@ -274,7 +274,7 @@ const MarkdownViewer = forwardRef(({ conten hr(props) { return
; }, - }), [highlighter, viewTheme]); + }), [highlighter, viewTheme, settings.plantumlRendererMode]); return (