slideshare/src/components/presentations/PresentationMode.tsx
Michael Mainguy 72cce3af0f 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>
2025-08-22 05:41:50 -05:00

204 lines
6.8 KiB
TypeScript

import React, { useState, useEffect} from 'react';
import { useParams, useNavigate } from 'react-router-dom';
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";
import type { SlideContent } from '../../types/presentation.ts';
import {useKeyboardNavigation} from "./hooks/useKeyboardNavigation.ts";
export const PresentationMode: React.FC = () => {
const { presentationId, slideNumber } = useParams<{
presentationId: string;
slideNumber: string;
}>();
const navigate = useNavigate();
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);
// Navigate to specific slide and update URL
const goToSlide = (slideIndex: number) => {
if (!presentation) return;
const clampedIndex = Math.max(0, Math.min(slideIndex, presentation.slides.length - 1));
setCurrentSlideIndex(clampedIndex);
navigate(`/presentations/${presentationId}/present/${clampedIndex + 1}`, { replace: true });
};
// Keyboard navigation handler
useKeyboardNavigation({
totalSlides: presentation?.slides.length || 0,
currentSlideIndex,
onNavigate: goToSlide
});
// Sync current slide index with URL parameter
useEffect(() => {
if (slideNumber) {
const newIndex = parseInt(slideNumber, 10) - 1;
if (newIndex >= 0 && newIndex !== currentSlideIndex) {
setCurrentSlideIndex(newIndex);
}
}
}, [slideNumber]);
// 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);
if (!layout) {
loggers.ui.warn(`Layout ${slide.layoutId} not found in theme ${theme.name}`);
return '<div class="error">Layout not found</div>';
}
let renderedTemplate = layout.htmlTemplate;
// Replace template variables with slide content
for (const [slotId, content] of Object.entries(slide.content)) {
const regex = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
// Find the corresponding slot to determine processing type
const slot = layout.slots.find(s => s.id === slotId);
let processedContent = content;
// Process based on slot type
if (slot?.type === 'code') {
// 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);
return sanitizeSlideTemplate(renderedTemplate);
};
if (loading) {
return (
<div className="presentation-mode loading">
<div className="loading-spinner">Loading presentation...</div>
</div>
);
}
if (error) {
return (
<div className="presentation-mode error">
<div className="error-content">
<h2>Error Loading Presentation</h2>
<p>{error}</p>
<button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode
</button>
</div>
</div>
);
}
if (!presentation || !theme) {
return (
<div className="presentation-mode error">
<div className="error-content">
<h2>Presentation Not Found</h2>
<button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode
</button>
</div>
</div>
);
}
if (presentation.slides.length === 0) {
return (
<div className="presentation-mode error">
<div className="error-content">
<h2>No Slides Available</h2>
<p>This presentation is empty.</p>
<button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode
</button>
</div>
</div>
);
}
const totalSlides = presentation.slides.length;
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>
{/* Navigation indicator */}
<div className="navigation-indicator">
<span className="slide-counter">
{currentSlideIndex + 1} / {totalSlides}
</span>
<div className="navigation-hints">
<span> Space: Navigate</span>
<span>Esc: Exit</span>
</div>
</div>
</div>
);
};