From 72cce3af0f275b9784e1ca88a7538674bc03d92c Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Fri, 22 Aug 2025 05:41:50 -0500 Subject: [PATCH] Add Mermaid diagram support with dedicated diagram-slide layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement complete Mermaid.js integration for rich diagram rendering - Add diagram-slide.html layout with title, diagram, and notes slots - Create diagramProcessor.ts with async rendering and error handling - Add comprehensive SVG element support to HTML sanitizer - Implement async template rendering system for diagram processing - Add SlidePreview component with loading states for better UX - Support all major Mermaid diagram types (flowchart, sequence, gantt, pie, etc.) - Add dark theme integration with custom color scheme - Include diagram-specific styling and responsive design - Add diagram field editor with syntax highlighting styling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- public/themes-manifest.json | 3 +- .../themes/default/layouts/diagram-slide.html | 23 +++ public/themes/default/style.css | 137 ++++++++++++++ .../presentations/PresentationMode.tsx | 45 ++++- src/components/slide-editor/ContentEditor.tsx | 45 ++--- src/components/slide-editor/SlidePreview.tsx | 71 ++++++++ src/components/slide-editor/utils.ts | 10 +- src/utils/diagramProcessor.ts | 170 ++++++++++++++++++ src/utils/htmlSanitizer.ts | 12 +- 9 files changed, 476 insertions(+), 40 deletions(-) create mode 100644 public/themes/default/layouts/diagram-slide.html create mode 100644 src/components/slide-editor/SlidePreview.tsx create mode 100644 src/utils/diagramProcessor.ts diff --git a/public/themes-manifest.json b/public/themes-manifest.json index 7471d4e..e9dd450 100644 --- a/public/themes-manifest.json +++ b/public/themes-manifest.json @@ -7,6 +7,7 @@ "2-content-blocks", "code-slide", "content-slide", + "diagram-slide", "image-slide", "markdown-slide", "title-slide" @@ -14,5 +15,5 @@ "hasMasterSlide": true } }, - "generated": "2025-08-22T02:19:52.970Z" + "generated": "2025-08-22T02:33:14.669Z" } \ No newline at end of file diff --git a/public/themes/default/layouts/diagram-slide.html b/public/themes/default/layouts/diagram-slide.html new file mode 100644 index 0000000..eb1cd8e --- /dev/null +++ b/public/themes/default/layouts/diagram-slide.html @@ -0,0 +1,23 @@ +
+

+ {{title}} +

+ +
{{diagram}}
+ +
+ {{notes}} +
+
\ No newline at end of file diff --git a/public/themes/default/style.css b/public/themes/default/style.css index 48a3619..219afc9 100644 --- a/public/themes/default/style.css +++ b/public/themes/default/style.css @@ -720,6 +720,143 @@ pre.code-content::before { font-style: italic; } +/* Diagram editor textarea styling */ +.field-textarea.diagram-field { + font-family: var(--theme-font-code); + font-size: 0.9rem; + line-height: 1.4; + background: #0f1419; + color: #e6e6e6; + border: 1px solid var(--theme-accent); + border-radius: 4px; + padding: 0.75rem; + white-space: pre; + tab-size: 2; +} + +.field-textarea.diagram-field::placeholder { + color: #6a6a6a; + font-style: italic; +} + +/* Diagram slide layout */ +.layout-diagram-slide, +.slide-container .layout-diagram-slide { + justify-content: flex-start; + align-items: stretch; + text-align: center; + padding: 2rem; +} + +.layout-diagram-slide .slot[data-slot="title"] { + font-size: clamp(1.5rem, 4vw, 2rem); + margin-bottom: 1.5rem; + text-align: center; + color: var(--theme-text); +} + +.layout-diagram-slide .slot[data-slot="diagram"] { + flex: 1; + margin-bottom: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.layout-diagram-slide .slot[data-slot="notes"] { + font-size: clamp(0.9rem, 2vw, 1rem); + color: var(--theme-text-secondary); + line-height: 1.5; + text-align: center; + max-height: 20vh; + overflow-y: auto; +} + +/* Enhanced Mermaid diagram styling */ +.mermaid-container { + margin: 1rem 0; + padding: 1.5rem; + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; + border: 1px solid var(--theme-secondary); + text-align: center; + overflow: hidden; + width: 100%; + max-width: 100%; +} + +.mermaid-diagram { + max-width: 100%; + height: auto; + display: flex; + align-items: center; + justify-content: center; +} + +.mermaid-diagram svg { + max-width: 100%; + height: auto; + background: transparent; + border-radius: 8px; +} + +.diagram-error { + color: #ff6b6b; + background: rgba(255, 107, 107, 0.1); + padding: 1.5rem; + border-radius: 8px; + border: 1px solid #ff6b6b; + font-family: var(--theme-font-code); + font-size: 0.9rem; + text-align: left; +} + +.diagram-error strong { + display: block; + margin-bottom: 0.5rem; + color: #ff8a8a; +} + +.error-content { + background: rgba(0, 0, 0, 0.2); + padding: 0.75rem; + border-radius: 4px; + margin-top: 0.75rem; + white-space: pre-wrap; + font-size: 0.8rem; + overflow-x: auto; +} + +/* Preview loading states */ +.preview-loading, +.slide-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: var(--theme-text-secondary); + font-size: 0.9rem; + z-index: 10; +} + +.loading-spinner { + width: 2rem; + height: 2rem; + border: 2px solid var(--theme-secondary); + border-top: 2px solid var(--theme-accent); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + /* Responsive adjustments for enhanced features */ @media (max-width: 768px) { .layout-code-slide { diff --git a/src/components/presentations/PresentationMode.tsx b/src/components/presentations/PresentationMode.tsx index fcf7d7a..ca74f0e 100644 --- a/src/components/presentations/PresentationMode.tsx +++ b/src/components/presentations/PresentationMode.tsx @@ -5,6 +5,7 @@ import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts'; import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts'; import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts'; import { highlightCode } from '../../utils/codeHighlighter.ts'; +import { renderDiagram } from '../../utils/diagramProcessor.ts'; import { loggers } from '../../utils/logger.ts'; import './PresentationMode.css'; import {usePresentationLoader} from "./hooks/usePresentationLoader.ts"; @@ -21,6 +22,8 @@ export const PresentationMode: React.FC = () => { const [currentSlideIndex, setCurrentSlideIndex] = useState( slideNumber ? parseInt(slideNumber, 10) - 1 : 0 ); + const [renderedSlideContent, setRenderedSlideContent] = useState(''); + const [isRenderingSlide, setIsRenderingSlide] = useState(false); // Load presentation and theme const { presentation, theme, loading, error } = usePresentationLoader(presentationId); @@ -52,7 +55,31 @@ export const PresentationMode: React.FC = () => { } }, [slideNumber]); - const renderSlideContent = (slide: SlideContent): string => { + // Render current slide content when slide changes + useEffect(() => { + const renderCurrentSlide = async () => { + if (!presentation || !theme || currentSlideIndex < 0 || currentSlideIndex >= presentation.slides.length) { + setRenderedSlideContent(''); + return; + } + + setIsRenderingSlide(true); + try { + const slide = presentation.slides[currentSlideIndex]; + const rendered = await renderSlideContent(slide); + setRenderedSlideContent(rendered); + } catch (error) { + console.error('Failed to render slide:', error); + setRenderedSlideContent('
Failed to render slide
'); + } finally { + setIsRenderingSlide(false); + } + }; + + renderCurrentSlide(); + }, [presentation, theme, currentSlideIndex]); + + const renderSlideContent = async (slide: SlideContent): Promise => { if (!theme) return ''; const layout = theme.layouts.find(l => l.id === slide.layoutId); @@ -64,7 +91,7 @@ export const PresentationMode: React.FC = () => { let renderedTemplate = layout.htmlTemplate; // Replace template variables with slide content - Object.entries(slide.content).forEach(([slotId, content]) => { + for (const [slotId, content] of Object.entries(slide.content)) { const regex = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g'); // Find the corresponding slot to determine processing type @@ -77,13 +104,16 @@ export const PresentationMode: React.FC = () => { // Handle code highlighting const language = slot.attributes?.['data-language'] || 'javascript'; processedContent = highlightCode(content, language); + } else if (slot?.type === 'diagram') { + // Handle diagram rendering + processedContent = await renderDiagram(content); } else if (slot?.type === 'markdown' || (slot?.type === 'text' && isMarkdownContent(content))) { // Handle markdown processing processedContent = renderSlideMarkdown(content, slot?.type); } renderedTemplate = renderedTemplate.replace(regex, processedContent); - }); + } // Handle conditional blocks and clean up remaining variables renderedTemplate = renderTemplateWithSampleData(renderedTemplate, layout); @@ -140,16 +170,21 @@ export const PresentationMode: React.FC = () => { ); } - const currentSlide = presentation.slides[currentSlideIndex]; const totalSlides = presentation.slides.length; - const renderedSlideContent = renderSlideContent(currentSlide); return (
+ {isRenderingSlide && ( +
+
+ Rendering slide... +
+ )}
diff --git a/src/components/slide-editor/ContentEditor.tsx b/src/components/slide-editor/ContentEditor.tsx index 7ac6eb6..99f8acc 100644 --- a/src/components/slide-editor/ContentEditor.tsx +++ b/src/components/slide-editor/ContentEditor.tsx @@ -1,11 +1,10 @@ import React from 'react'; import type { Presentation } from '../../types/presentation.ts'; import type { SlideLayout } from '../../types/theme.ts'; -import { renderTemplateWithContent } from './utils.ts'; -import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts'; import { ImageUploadField } from '../ui/ImageUploadField.tsx'; import { CancelLink } from '../ui/buttons/CancelLink.tsx'; import { ActionButton } from '../ui/buttons/ActionButton.tsx'; +import { SlidePreview } from './SlidePreview.tsx'; interface ContentEditorProps { presentation: Presentation; @@ -45,11 +44,14 @@ export const ContentEditor: React.FC = ({ ); } - if (slot.type === 'code' || slot.type === 'markdown' || (slot.type === 'text' && slot.id.includes('content'))) { + if (slot.type === 'code' || slot.type === 'diagram' || slot.type === 'markdown' || (slot.type === 'text' && slot.id.includes('content'))) { const getPlaceholder = () => { if (slot.type === 'code') { return slot.placeholder || 'Enter your JavaScript code here...'; } + if (slot.type === 'diagram') { + return slot.placeholder || 'Enter Mermaid diagram syntax here...'; + } if (slot.type === 'markdown') { return slot.placeholder || 'Enter markdown content (e.g., **bold**, *italic*, - list items)'; } @@ -58,6 +60,7 @@ export const ContentEditor: React.FC = ({ const getRows = () => { if (slot.type === 'code') return 8; + if (slot.type === 'diagram') return 10; if (slot.type === 'markdown') return 6; return 4; }; @@ -68,7 +71,11 @@ export const ContentEditor: React.FC = ({ value={slideContent[slot.id] || ''} onChange={(e) => onSlotContentChange(slot.id, e.target.value)} placeholder={getPlaceholder()} - className={`field-textarea ${slot.type === 'code' ? 'code-field' : slot.type === 'markdown' ? 'markdown-field' : ''}`} + className={`field-textarea ${ + slot.type === 'code' ? 'code-field' : + slot.type === 'diagram' ? 'diagram-field' : + slot.type === 'markdown' ? 'markdown-field' : '' + }`} rows={getRows()} style={slot.type === 'code' ? { fontFamily: 'var(--theme-font-code)' } : undefined} /> @@ -149,31 +156,11 @@ export const ContentEditor: React.FC = ({
-
-

Live Preview

-

- Updates automatically as you type -

-
-
-
-
-
- {selectedLayout.name} - - {presentation.metadata.aspectRatio || '16:9'} aspect ratio - - - {Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled - -
-
-
+
); diff --git a/src/components/slide-editor/SlidePreview.tsx b/src/components/slide-editor/SlidePreview.tsx new file mode 100644 index 0000000..0081e4c --- /dev/null +++ b/src/components/slide-editor/SlidePreview.tsx @@ -0,0 +1,71 @@ +import React, { useState, useEffect } from 'react'; +import type { Presentation } from '../../types/presentation.ts'; +import type { SlideLayout } from '../../types/theme.ts'; +import { renderTemplateWithContent } from './utils.ts'; +import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts'; + +interface SlidePreviewProps { + presentation: Presentation; + selectedLayout: SlideLayout; + slideContent: Record; +} + +export const SlidePreview: React.FC = ({ + presentation, + selectedLayout, + slideContent, +}) => { + const [previewHtml, setPreviewHtml] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const updatePreview = async () => { + setIsLoading(true); + try { + const rendered = await renderTemplateWithContent(selectedLayout, slideContent); + const sanitized = sanitizeSlideTemplate(rendered); + setPreviewHtml(sanitized); + } catch (error) { + console.error('Preview rendering failed:', error); + setPreviewHtml('
Preview unavailable
'); + } finally { + setIsLoading(false); + } + }; + + updatePreview(); + }, [selectedLayout, slideContent]); + + return ( +
+

Live Preview

+

+ Updates automatically as you type +

+
+
+ {isLoading && ( +
+
+ Rendering preview... +
+ )} +
+
+
+ {selectedLayout.name} + + {presentation.metadata.aspectRatio || '16:9'} aspect ratio + + + {Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/slide-editor/utils.ts b/src/components/slide-editor/utils.ts index cccc324..632b1b5 100644 --- a/src/components/slide-editor/utils.ts +++ b/src/components/slide-editor/utils.ts @@ -1,13 +1,14 @@ import type { SlideLayout } from '../../types/theme.ts'; import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts'; import { highlightCode } from '../../utils/codeHighlighter.ts'; +import { renderDiagram } from '../../utils/diagramProcessor.ts'; // Helper function to render template with actual content -export const renderTemplateWithContent = (layout: SlideLayout, content: Record): string => { +export const renderTemplateWithContent = async (layout: SlideLayout, content: Record): Promise => { let rendered = layout.htmlTemplate; // Replace content placeholders - Object.entries(content).forEach(([slotId, value]) => { + for (const [slotId, value] of Object.entries(content)) { const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g'); // Find the corresponding slot to determine processing type @@ -20,13 +21,16 @@ export const renderTemplateWithContent = (layout: SlideLayout, content: Record => { + if (!diagramText || typeof diagramText !== 'string') { + return '
No diagram content provided
'; + } + + try { + // Clean and validate the diagram text + const cleanedText = diagramText.trim(); + + if (!cleanedText) { + return '
Empty diagram content
'; + } + + // Generate unique ID for this diagram + const diagramId = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + console.log('Rendering Mermaid diagram:', cleanedText); + + // Render the diagram directly without parse validation (parse might be causing issues) + const { svg } = await mermaid.render(diagramId, cleanedText); + + console.log('Mermaid SVG generated:', svg.substring(0, 100) + '...'); + + // Wrap in container with proper styling + const result = `
+
${svg}
+
`; + + console.log('Final HTML result:', result.substring(0, 200) + '...'); + + return result; + + } catch (error) { + console.error('Mermaid diagram rendering failed:', error); + + // Return error message with the original text for debugging + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return `
+ Diagram Error: ${errorMessage} +
${diagramText.replace(/[<>&"']/g, (char) => {
+        const escapeMap: Record = {
+          '<': '<',
+          '>': '>',
+          '&': '&',
+          '"': '"',
+          "'": '''
+        };
+        return escapeMap[char];
+      })}
+
`; + } +}; + +/** + * Checks if content appears to be Mermaid diagram syntax + * Used to determine whether to process content as a diagram + */ +export const isDiagramContent = (content: string): boolean => { + if (!content || typeof content !== 'string') { + return false; + } + + const trimmed = content.trim(); + + // Common Mermaid diagram types + const mermaidPatterns = [ + /^graph\s+(TD|TB|BT|RL|LR)/i, // Flowchart + /^flowchart\s+(TD|TB|BT|RL|LR)/i, // Flowchart (newer syntax) + /^sequenceDiagram/i, // Sequence diagram + /^classDiagram/i, // Class diagram + /^stateDiagram/i, // State diagram + /^erDiagram/i, // Entity relationship diagram + /^gantt/i, // Gantt chart + /^pie/i, // Pie chart + /^journey/i, // User journey + /^gitgraph/i, // Git graph + /^mindmap/i, // Mind map + /^timeline/i, // Timeline + ]; + + return mermaidPatterns.some(pattern => pattern.test(trimmed)); +}; + +/** + * Sample diagram content for testing and previews + */ +export const SAMPLE_DIAGRAM_CONTENT = { + flowchart: `graph TD + A[Start] --> B{Is it working?} + B -->|Yes| C[Great!] + B -->|No| D[Debug] + D --> B + C --> E[End]`, + + sequence: `sequenceDiagram + participant A as Alice + participant B as Bob + participant C as Charlie + A->>B: Hello Bob! + B->>C: Hello Charlie! + C->>A: Hello Alice!`, + + pie: `pie title Project Status + "Completed" : 65 + "In Progress" : 25 + "Planning" : 10`, + + gantt: `gantt + title Project Timeline + dateFormat YYYY-MM-DD + section Planning + Research :done, des1, 2024-01-01, 2024-01-15 + Design :done, des2, 2024-01-10, 2024-01-25 + section Development + Frontend :active, dev1, 2024-01-20, 2024-02-15 + Backend :dev2, 2024-02-01, 2024-02-28 + Testing :test1, 2024-02-20, 2024-03-10` +}; \ No newline at end of file diff --git a/src/utils/htmlSanitizer.ts b/src/utils/htmlSanitizer.ts index a837c19..a92c74e 100644 --- a/src/utils/htmlSanitizer.ts +++ b/src/utils/htmlSanitizer.ts @@ -49,10 +49,18 @@ const DEFAULT_SLIDE_CONFIG: Required = { // Quotes 'blockquote', 'cite', // Code elements - 'pre', 'code' + 'pre', 'code', + // SVG elements for diagrams + 'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', + 'text', 'tspan', 'defs', 'marker', 'pattern', 'foreignObject' ], allowedAttributes: [ - 'class', 'id', 'style', 'data-*' + 'class', 'id', 'style', 'data-*', + // SVG attributes + 'xmlns', 'viewBox', 'width', 'height', 'd', 'x', 'y', 'x1', 'y1', 'x2', 'y2', + 'cx', 'cy', 'r', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'stroke-dasharray', + 'transform', 'points', 'font-family', 'font-size', 'text-anchor', 'dominant-baseline', + 'markerWidth', 'markerHeight', 'orient', 'refX', 'refY' ] };