Add Mermaid diagram support with dedicated diagram-slide layout

- 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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-22 05:41:50 -05:00
parent 69be84e5ab
commit 72cce3af0f
9 changed files with 476 additions and 40 deletions

View File

@ -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"
}

View File

@ -0,0 +1,23 @@
<div class="slide layout-diagram-slide">
<h1 class="slot title-slot"
data-slot="title"
data-type="title"
data-placeholder="Diagram Title"
data-required>
{{title}}
</h1>
<div class="slot diagram-content"
data-slot="diagram"
data-type="diagram"
data-placeholder="Enter Mermaid diagram syntax here..."
data-multiline="true">{{diagram}}</div>
<div class="slot notes-content"
data-slot="notes"
data-type="text"
data-placeholder="Optional diagram explanation..."
data-multiline="true">
{{notes}}
</div>
</div>

View File

@ -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 {

View File

@ -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<string>('');
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('<div class="error">Failed to render slide</div>');
} finally {
setIsRenderingSlide(false);
}
};
renderCurrentSlide();
}, [presentation, theme, currentSlideIndex]);
const renderSlideContent = async (slide: SlideContent): Promise<string> => {
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 (
<div className="presentation-mode fullscreen">
<div className="slide-container">
{isRenderingSlide && (
<div className="slide-loading">
<div className="loading-spinner"></div>
<span>Rendering slide...</span>
</div>
)}
<div
className={`slide-content ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{ __html: renderedSlideContent }}
style={{ opacity: isRenderingSlide ? 0.5 : 1 }}
/>
</div>

View File

@ -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<ContentEditorProps> = ({
);
}
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<ContentEditorProps> = ({
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<ContentEditorProps> = ({
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<ContentEditorProps> = ({
</div>
</div>
<div className="content-preview">
<h3>Live Preview</h3>
<p className="preview-description">
Updates automatically as you type
</p>
<div className="preview-container">
<div className="slide-preview-wrapper">
<div
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
}}
/>
</div>
<div className="preview-meta">
<span className="layout-name">{selectedLayout.name}</span>
<span className="aspect-ratio-info">
{presentation.metadata.aspectRatio || '16:9'} aspect ratio
</span>
<span className="content-count">
{Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled
</span>
</div>
</div>
</div>
<SlidePreview
presentation={presentation}
selectedLayout={selectedLayout}
slideContent={slideContent}
/>
</div>
</div>
);

View File

@ -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<string, string>;
}
export const SlidePreview: React.FC<SlidePreviewProps> = ({
presentation,
selectedLayout,
slideContent,
}) => {
const [previewHtml, setPreviewHtml] = useState<string>('');
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('<div class="preview-error">Preview unavailable</div>');
} finally {
setIsLoading(false);
}
};
updatePreview();
}, [selectedLayout, slideContent]);
return (
<div className="content-preview">
<h3>Live Preview</h3>
<p className="preview-description">
Updates automatically as you type
</p>
<div className="preview-container">
<div className="slide-preview-wrapper">
{isLoading && (
<div className="preview-loading">
<div className="loading-spinner"></div>
<span>Rendering preview...</span>
</div>
)}
<div
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{ __html: previewHtml }}
style={{ opacity: isLoading ? 0.5 : 1 }}
/>
</div>
<div className="preview-meta">
<span className="layout-name">{selectedLayout.name}</span>
<span className="aspect-ratio-info">
{presentation.metadata.aspectRatio || '16:9'} aspect ratio
</span>
<span className="content-count">
{Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled
</span>
</div>
</div>
</div>
);
};

View File

@ -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, string>): string => {
export const renderTemplateWithContent = async (layout: SlideLayout, content: Record<string, string>): Promise<string> => {
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<s
// Handle code highlighting
const language = slot.attributes?.['data-language'] || 'javascript';
processedValue = highlightCode(value || '', language);
} else if (slot?.type === 'diagram') {
// Handle diagram rendering
processedValue = await renderDiagram(value || '');
} else if (slot?.type === 'markdown' || (slot?.type === 'text' && isMarkdownContent(value || ''))) {
// Handle markdown processing
processedValue = renderSlideMarkdown(value || '', slot?.type);
}
rendered = rendered.replace(placeholder, processedValue);
});
}
// Clean up any remaining placeholders
rendered = rendered.replace(/\{\{[^}]+\}\}/g, '');

View File

@ -0,0 +1,170 @@
import mermaid from 'mermaid';
/**
* Mermaid diagram processor for slide content
* Uses Mermaid library for diagram rendering
*/
// Configure Mermaid for slide-safe diagram rendering
mermaid.initialize({
startOnLoad: false, // We'll manually trigger rendering
theme: 'dark', // Match our dark theme
themeVariables: {
primaryColor: '#9563eb',
primaryTextColor: '#ffffff',
primaryBorderColor: '#94748b',
lineColor: '#eea5e9',
sectionBkColor: '#001112',
altSectionBkColor: '#94748b',
gridColor: '#94a4ab',
secondaryColor: '#94748b',
tertiaryColor: '#eea5e9'
},
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 16,
flowchart: {
htmlLabels: true,
curve: 'basis'
},
sequence: {
diagramMarginX: 50,
diagramMarginY: 10,
actorMargin: 50,
width: 150,
height: 65,
boxMargin: 10,
boxTextMargin: 5,
noteMargin: 10,
messageMargin: 35
},
gantt: {
titleTopMargin: 25,
barHeight: 20,
gridLineStartPadding: 35,
fontSize: 11
}
});
/**
* Renders Mermaid diagram from text syntax
* @param diagramText - The Mermaid diagram syntax
* @returns Promise resolving to HTML string with rendered diagram
*/
export const renderDiagram = async (diagramText: string): Promise<string> => {
if (!diagramText || typeof diagramText !== 'string') {
return '<div class="diagram-error">No diagram content provided</div>';
}
try {
// Clean and validate the diagram text
const cleanedText = diagramText.trim();
if (!cleanedText) {
return '<div class="diagram-error">Empty diagram content</div>';
}
// 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 = `<div class="mermaid-container">
<div class="mermaid-diagram" id="${diagramId}">${svg}</div>
</div>`;
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 `<div class="diagram-error">
<strong>Diagram Error:</strong> ${errorMessage}
<pre class="error-content">${diagramText.replace(/[<>&"']/g, (char) => {
const escapeMap: Record<string, string> = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&#x27;'
};
return escapeMap[char];
})}</pre>
</div>`;
}
};
/**
* 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`
};

View File

@ -49,10 +49,18 @@ const DEFAULT_SLIDE_CONFIG: Required<SanitizeConfig> = {
// 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'
]
};