slideshare/src/components/presentations/PresentationMode.tsx
Michael Mainguy 655e324c88 Add secure markdown support to slide templates
- Implement safe markdown processing with marked and DOMPurify
- Add markdown-slide layout template with dedicated markdown slots
- Support auto-detection of markdown content in text slots
- Include comprehensive markdown styling (lists, headers, code, quotes, tables)
- Maintain security with HTML sanitization and safe element filtering
- Add markdown documentation to theme creation guidelines

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 20:43:26 -05:00

161 lines
5.3 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 { 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
);
// 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]);
const renderSlideContent = (slide: SlideContent): 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
Object.entries(slide.content).forEach(([slotId, content]) => {
const regex = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
// Find the corresponding slot to determine if it should be processed as markdown
const slot = layout.slots.find(s => s.id === slotId);
const shouldProcessAsMarkdown = slot?.type === 'markdown' ||
(slot?.type === 'text' && isMarkdownContent(content));
const processedContent = shouldProcessAsMarkdown ?
renderSlideMarkdown(content, slot?.type) : content;
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 currentSlide = presentation.slides[currentSlideIndex];
const totalSlides = presentation.slides.length;
const renderedSlideContent = renderSlideContent(currentSlide);
return (
<div className="presentation-mode fullscreen">
<div className="slide-container">
<div
className={`slide-content ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{ __html: renderedSlideContent }}
/>
</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>
);
};