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:
parent
69be84e5ab
commit
72cce3af0f
@ -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"
|
||||
}
|
23
public/themes/default/layouts/diagram-slide.html
Normal file
23
public/themes/default/layouts/diagram-slide.html
Normal 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>
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
71
src/components/slide-editor/SlidePreview.tsx
Normal file
71
src/components/slide-editor/SlidePreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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, '');
|
||||
|
170
src/utils/diagramProcessor.ts
Normal file
170
src/utils/diagramProcessor.ts
Normal 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> = {
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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`
|
||||
};
|
@ -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'
|
||||
]
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user