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",
|
"2-content-blocks",
|
||||||
"code-slide",
|
"code-slide",
|
||||||
"content-slide",
|
"content-slide",
|
||||||
|
"diagram-slide",
|
||||||
"image-slide",
|
"image-slide",
|
||||||
"markdown-slide",
|
"markdown-slide",
|
||||||
"title-slide"
|
"title-slide"
|
||||||
@ -14,5 +15,5 @@
|
|||||||
"hasMasterSlide": true
|
"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;
|
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 */
|
/* Responsive adjustments for enhanced features */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.layout-code-slide {
|
.layout-code-slide {
|
||||||
|
@ -5,6 +5,7 @@ import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
|
|||||||
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
|
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
|
||||||
import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts';
|
import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts';
|
||||||
import { highlightCode } from '../../utils/codeHighlighter.ts';
|
import { highlightCode } from '../../utils/codeHighlighter.ts';
|
||||||
|
import { renderDiagram } from '../../utils/diagramProcessor.ts';
|
||||||
import { loggers } from '../../utils/logger.ts';
|
import { loggers } from '../../utils/logger.ts';
|
||||||
import './PresentationMode.css';
|
import './PresentationMode.css';
|
||||||
import {usePresentationLoader} from "./hooks/usePresentationLoader.ts";
|
import {usePresentationLoader} from "./hooks/usePresentationLoader.ts";
|
||||||
@ -21,6 +22,8 @@ export const PresentationMode: React.FC = () => {
|
|||||||
const [currentSlideIndex, setCurrentSlideIndex] = useState(
|
const [currentSlideIndex, setCurrentSlideIndex] = useState(
|
||||||
slideNumber ? parseInt(slideNumber, 10) - 1 : 0
|
slideNumber ? parseInt(slideNumber, 10) - 1 : 0
|
||||||
);
|
);
|
||||||
|
const [renderedSlideContent, setRenderedSlideContent] = useState<string>('');
|
||||||
|
const [isRenderingSlide, setIsRenderingSlide] = useState(false);
|
||||||
|
|
||||||
// Load presentation and theme
|
// Load presentation and theme
|
||||||
const { presentation, theme, loading, error } = usePresentationLoader(presentationId);
|
const { presentation, theme, loading, error } = usePresentationLoader(presentationId);
|
||||||
@ -52,7 +55,31 @@ export const PresentationMode: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [slideNumber]);
|
}, [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 '';
|
if (!theme) return '';
|
||||||
|
|
||||||
const layout = theme.layouts.find(l => l.id === slide.layoutId);
|
const layout = theme.layouts.find(l => l.id === slide.layoutId);
|
||||||
@ -64,7 +91,7 @@ export const PresentationMode: React.FC = () => {
|
|||||||
let renderedTemplate = layout.htmlTemplate;
|
let renderedTemplate = layout.htmlTemplate;
|
||||||
|
|
||||||
// Replace template variables with slide content
|
// 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');
|
const regex = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
|
||||||
|
|
||||||
// Find the corresponding slot to determine processing type
|
// Find the corresponding slot to determine processing type
|
||||||
@ -77,13 +104,16 @@ export const PresentationMode: React.FC = () => {
|
|||||||
// Handle code highlighting
|
// Handle code highlighting
|
||||||
const language = slot.attributes?.['data-language'] || 'javascript';
|
const language = slot.attributes?.['data-language'] || 'javascript';
|
||||||
processedContent = highlightCode(content, language);
|
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))) {
|
} else if (slot?.type === 'markdown' || (slot?.type === 'text' && isMarkdownContent(content))) {
|
||||||
// Handle markdown processing
|
// Handle markdown processing
|
||||||
processedContent = renderSlideMarkdown(content, slot?.type);
|
processedContent = renderSlideMarkdown(content, slot?.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderedTemplate = renderedTemplate.replace(regex, processedContent);
|
renderedTemplate = renderedTemplate.replace(regex, processedContent);
|
||||||
});
|
}
|
||||||
|
|
||||||
// Handle conditional blocks and clean up remaining variables
|
// Handle conditional blocks and clean up remaining variables
|
||||||
renderedTemplate = renderTemplateWithSampleData(renderedTemplate, layout);
|
renderedTemplate = renderTemplateWithSampleData(renderedTemplate, layout);
|
||||||
@ -140,16 +170,21 @@ export const PresentationMode: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSlide = presentation.slides[currentSlideIndex];
|
|
||||||
const totalSlides = presentation.slides.length;
|
const totalSlides = presentation.slides.length;
|
||||||
const renderedSlideContent = renderSlideContent(currentSlide);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="presentation-mode fullscreen">
|
<div className="presentation-mode fullscreen">
|
||||||
<div className="slide-container">
|
<div className="slide-container">
|
||||||
|
{isRenderingSlide && (
|
||||||
|
<div className="slide-loading">
|
||||||
|
<div className="loading-spinner"></div>
|
||||||
|
<span>Rendering slide...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={`slide-content ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
|
className={`slide-content ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
|
||||||
dangerouslySetInnerHTML={{ __html: renderedSlideContent }}
|
dangerouslySetInnerHTML={{ __html: renderedSlideContent }}
|
||||||
|
style={{ opacity: isRenderingSlide ? 0.5 : 1 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Presentation } from '../../types/presentation.ts';
|
import type { Presentation } from '../../types/presentation.ts';
|
||||||
import type { SlideLayout } from '../../types/theme.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 { ImageUploadField } from '../ui/ImageUploadField.tsx';
|
||||||
import { CancelLink } from '../ui/buttons/CancelLink.tsx';
|
import { CancelLink } from '../ui/buttons/CancelLink.tsx';
|
||||||
import { ActionButton } from '../ui/buttons/ActionButton.tsx';
|
import { ActionButton } from '../ui/buttons/ActionButton.tsx';
|
||||||
|
import { SlidePreview } from './SlidePreview.tsx';
|
||||||
|
|
||||||
interface ContentEditorProps {
|
interface ContentEditorProps {
|
||||||
presentation: Presentation;
|
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 = () => {
|
const getPlaceholder = () => {
|
||||||
if (slot.type === 'code') {
|
if (slot.type === 'code') {
|
||||||
return slot.placeholder || 'Enter your JavaScript code here...';
|
return slot.placeholder || 'Enter your JavaScript code here...';
|
||||||
}
|
}
|
||||||
|
if (slot.type === 'diagram') {
|
||||||
|
return slot.placeholder || 'Enter Mermaid diagram syntax here...';
|
||||||
|
}
|
||||||
if (slot.type === 'markdown') {
|
if (slot.type === 'markdown') {
|
||||||
return slot.placeholder || 'Enter markdown content (e.g., **bold**, *italic*, - list items)';
|
return slot.placeholder || 'Enter markdown content (e.g., **bold**, *italic*, - list items)';
|
||||||
}
|
}
|
||||||
@ -58,6 +60,7 @@ export const ContentEditor: React.FC<ContentEditorProps> = ({
|
|||||||
|
|
||||||
const getRows = () => {
|
const getRows = () => {
|
||||||
if (slot.type === 'code') return 8;
|
if (slot.type === 'code') return 8;
|
||||||
|
if (slot.type === 'diagram') return 10;
|
||||||
if (slot.type === 'markdown') return 6;
|
if (slot.type === 'markdown') return 6;
|
||||||
return 4;
|
return 4;
|
||||||
};
|
};
|
||||||
@ -68,7 +71,11 @@ export const ContentEditor: React.FC<ContentEditorProps> = ({
|
|||||||
value={slideContent[slot.id] || ''}
|
value={slideContent[slot.id] || ''}
|
||||||
onChange={(e) => onSlotContentChange(slot.id, e.target.value)}
|
onChange={(e) => onSlotContentChange(slot.id, e.target.value)}
|
||||||
placeholder={getPlaceholder()}
|
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()}
|
rows={getRows()}
|
||||||
style={slot.type === 'code' ? { fontFamily: 'var(--theme-font-code)' } : undefined}
|
style={slot.type === 'code' ? { fontFamily: 'var(--theme-font-code)' } : undefined}
|
||||||
/>
|
/>
|
||||||
@ -149,31 +156,11 @@ export const ContentEditor: React.FC<ContentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content-preview">
|
<SlidePreview
|
||||||
<h3>Live Preview</h3>
|
presentation={presentation}
|
||||||
<p className="preview-description">
|
selectedLayout={selectedLayout}
|
||||||
Updates automatically as you type
|
slideContent={slideContent}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</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 type { SlideLayout } from '../../types/theme.ts';
|
||||||
import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts';
|
import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts';
|
||||||
import { highlightCode } from '../../utils/codeHighlighter.ts';
|
import { highlightCode } from '../../utils/codeHighlighter.ts';
|
||||||
|
import { renderDiagram } from '../../utils/diagramProcessor.ts';
|
||||||
|
|
||||||
// Helper function to render template with actual content
|
// 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;
|
let rendered = layout.htmlTemplate;
|
||||||
|
|
||||||
// Replace content placeholders
|
// Replace content placeholders
|
||||||
Object.entries(content).forEach(([slotId, value]) => {
|
for (const [slotId, value] of Object.entries(content)) {
|
||||||
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
|
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
|
||||||
|
|
||||||
// Find the corresponding slot to determine processing type
|
// Find the corresponding slot to determine processing type
|
||||||
@ -20,13 +21,16 @@ export const renderTemplateWithContent = (layout: SlideLayout, content: Record<s
|
|||||||
// Handle code highlighting
|
// Handle code highlighting
|
||||||
const language = slot.attributes?.['data-language'] || 'javascript';
|
const language = slot.attributes?.['data-language'] || 'javascript';
|
||||||
processedValue = highlightCode(value || '', language);
|
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 || ''))) {
|
} else if (slot?.type === 'markdown' || (slot?.type === 'text' && isMarkdownContent(value || ''))) {
|
||||||
// Handle markdown processing
|
// Handle markdown processing
|
||||||
processedValue = renderSlideMarkdown(value || '', slot?.type);
|
processedValue = renderSlideMarkdown(value || '', slot?.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered = rendered.replace(placeholder, processedValue);
|
rendered = rendered.replace(placeholder, processedValue);
|
||||||
});
|
}
|
||||||
|
|
||||||
// Clean up any remaining placeholders
|
// Clean up any remaining placeholders
|
||||||
rendered = rendered.replace(/\{\{[^}]+\}\}/g, '');
|
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
|
// Quotes
|
||||||
'blockquote', 'cite',
|
'blockquote', 'cite',
|
||||||
// Code elements
|
// 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: [
|
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