- 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>
161 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}; |