- 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>
204 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}; |