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>
This commit is contained in:
Michael Mainguy 2025-08-21 20:43:26 -05:00
parent ed0a57f802
commit 655e324c88
19 changed files with 2124 additions and 304 deletions

1318
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,13 @@
"generate-manifest": "node scripts/generate-themes-manifest.js"
},
"dependencies": {
"@types/prismjs": "^1.26.5",
"dompurify": "^3.2.6",
"loglevel": "^1.9.2",
"marked": "^16.2.0",
"mermaid": "^11.10.0",
"postcss": "^8.5.6",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.1"

View File

@ -5,12 +5,14 @@
"cssFile": "style.css",
"layouts": [
"2-content-blocks",
"code-slide",
"content-slide",
"image-slide",
"markdown-slide",
"title-slide"
],
"hasMasterSlide": true
}
},
"generated": "2025-08-21T21:35:36.361Z"
"generated": "2025-08-22T01:40:33.956Z"
}

View File

@ -114,6 +114,7 @@ Slots are editable areas defined with specific data attributes:
- **Text content**: `data-slot="content"`
- **Images**: `data-slot="image"` with `data-accept="image/*"`
- **Subtitles**: `data-slot="subtitle"`
- **Markdown content**: `data-slot="content" data-type="markdown"`
### Layout CSS Naming Convention
Style layouts using the pattern: `.layout-[layout-name]`
@ -335,6 +336,73 @@ Include print styles for presentation export:
}
```
## Markdown Support
### Markdown Slots
Themes can include slots that automatically process markdown content. This enables rich text formatting while maintaining security.
#### Creating Markdown Slots
```html
<div class="slot markdown-content"
data-slot="content"
data-type="markdown"
data-placeholder="Enter markdown content..."
data-multiline="true">
{{content}}
</div>
```
#### Supported Markdown Features
- **Headers**: `# H1`, `## H2`, `### H3`
- **Text formatting**: `**bold**`, `*italic*`, `~~strikethrough~~`
- **Lists**: Unordered (`-`, `*`) and ordered (`1.`)
- **Links**: `[text](url)`
- **Inline code**: `` `code` ``
- **Code blocks**: ````markdown```language````
- **Blockquotes**: `> quote`
- **Tables**: GitHub-flavored markdown tables
#### Security Features
- **DOMPurify sanitization**: All HTML is sanitized to prevent XSS
- **No script execution**: JavaScript and dangerous attributes are stripped
- **Safe HTML subset**: Only presentation-safe HTML elements allowed
- **URL filtering**: Only safe URL schemes (http, https, mailto) permitted
#### Styling Markdown Content
```css
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
font-family: var(--theme-font-heading);
color: var(--theme-primary);
margin: 1.5em 0 0.5em 0;
}
.markdown-content strong {
color: var(--theme-accent);
font-weight: 600;
}
.markdown-content code {
background: rgba(255, 255, 255, 0.1);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: var(--theme-font-code);
}
.markdown-content blockquote {
border-left: 4px solid var(--theme-accent);
padding-left: 1rem;
font-style: italic;
}
```
#### Auto-Detection
Text slots automatically detect markdown patterns and apply markdown rendering:
- Content with `#`, `**`, `*`, `-`, `>`, etc. is processed as markdown
- Set `data-type="markdown"` to force markdown processing
- Set `data-type="text"` to disable auto-detection
## Best Practices
1. **Use clamp() for responsive typography**: `font-size: clamp(min, preferred, max)`
@ -344,6 +412,7 @@ Include print styles for presentation export:
5. **Consider accessibility**: Use sufficient color contrast and readable fonts
6. **Use semantic HTML**: Proper heading hierarchy and meaningful class names
7. **Keep layouts flexible**: Use flexbox/grid for responsive behavior
8. **Use markdown for rich content**: Enable `data-type="markdown"` for formatted text slots
## Template Variables (Handlebars)

View File

@ -1,5 +1,5 @@
<div class="slide layout-2-content-blocks">
<h1 class="slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
<div class="fade-in slide layout-2-content-blocks">
<h1 class=" slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}}
</h1>
<div class="content-blocks-container">
@ -7,7 +7,10 @@
{{content1}}
</div>
<div class="slot content-slot" data-slot="content2" data-placeholder="Second content block" data-required>
<span class="fade-in-slow">
{{content2}}
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,8 @@
<div class="fade-in layout-content-slide">
<h1 class="slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}}
</h1>
<div id="code" class="slot content-area" data-slot="content" data-placeholder="Your content here..." data-multiline="true">
</div>
</div>

View File

@ -1,10 +1,7 @@
<div class="slide layout-content-slide">
<div class="fade-in layout-content-slide">
<h1 class="slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}}
</h1>
<h1 class="slot subtitle-slot" data-slot="subtitle" data-placeholder="Slide Subtitle" data-required>
{{subtitle}}
</h1>
<div class="slot content-area" data-slot="content" data-placeholder="Your content here..." data-multiline="true">
{{content}}
</div>

View File

@ -1,4 +1,4 @@
<div class="slide layout-image-slide">
<div class="fade-in slide layout-image-slide">
<h1 class="slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}}
</h1>

View File

@ -0,0 +1,17 @@
<div class="slide layout-markdown-slide">
<h1 class="slot title-slot"
data-slot="title"
data-type="title"
data-placeholder="Slide Title"
data-required>
{{title}}
</h1>
<div class="slot markdown-content"
data-slot="content"
data-type="markdown"
data-placeholder="Enter your markdown content here..."
data-multiline="true">
{{content}}
</div>
</div>

View File

@ -1,8 +1,5 @@
<div class="slide layout-title-slide">
<div class="fade-in slide layout-title-slide">
<h1 class="slot title-slot" data-slot="title" data-placeholder="Presentation Title" data-required>
{{title}}
</h1>
<div class="slot diagram-slot" data-slot="diagram" data-placeholder="Diagram or Image" data-required>
{{diagram}}
</div>
</div>

View File

@ -1,3 +1,3 @@
<div class="master-slide footer">
{{footerText}}
Here is some data
</div>

View File

@ -27,7 +27,7 @@
width: 100%;
height: 100%;
background: var(--theme-background);
color: var(--theme-text);
color: var(--theme-primary);
font-family: var(--theme-font-body);
padding: var(--slide-padding);
box-sizing: border-box;
@ -35,8 +35,6 @@
flex-direction: column;
position: relative;
overflow: hidden;
justify-content: center;
align-items: center;
/* Ensure content is properly centered within container */
text-align: center;
}
@ -69,10 +67,13 @@
/* Ensure slot content inherits proper centering */
text-align: inherit;
}
#main-image {
scale: .7;
.slot[data-slot="image"] {
width: 100%;
}
.slot[data-slot="image"] img {
width: 100%;
}
.slot:hover,
.slot.editing {
border-color: var(--theme-accent);
border-radius: 4px;
@ -92,20 +93,6 @@
font-style: italic;
}
/* Text slots */
.slot[data-type="title"] {
font-family: var(--theme-font-heading);
font-weight: bold;
line-height: 1.2;
text-align: center;
}
.slot[data-type="subtitle"] {
font-family: var(--theme-font-heading);
line-height: 1.4;
opacity: 0.8;
text-align: center;
}
.content-slot {
color: var(--theme-text-secondary);
background-color: var(--theme-background);
@ -115,101 +102,34 @@
/* Text content can be left-aligned for readability */
text-align: left;
}
/* Image slots */
.slot[data-type="image"] {
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
background: #fffffd;
border-radius: 8px;
}
.slot[data-type="image"] img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
max-width: 100px;
max-height: 100px;
border-radius: 4px;
}
/* Layout-specific styles */
/* Title slide layout */
.layout-title-slide,
.slide-container .layout-title-slide {
justify-content: center;
align-items: center;
text-align: center;
}
.layout-title-slide .slot[data-slot="title"] {
font-size: clamp(2rem, 8vw, 4rem);
.slot[data-slot="title"] {
font-size: clamp(5rem, 8vw, 4rem);
margin-bottom: 2rem;
width: 100%;
max-width: 80%;
color: var(--theme-primary);
width: 80%;
text-align: center;
}
.layout-title-slide .slot[data-slot="subtitle"] {
.slot[data-slot="subtitle"] {
font-size: clamp(1rem, 4vw, 2rem);
color: var(--theme-text-secondary);
width: 100%;
max-width: 80%;
text-align: center;
}
/* Content slide layout */
.layout-content-slide,
.slide-container .layout-content-slide {
justify-content: flex-start;
align-items: stretch;
/* Reset text alignment for content slides */
text-align: initial;
}
.layout-content-slide .slot[data-slot="title"] {
font-size: clamp(1.5rem, 6vw, 2.5rem);
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--theme-primary);
text-align: center;
width: 100%;
}
.layout-content-slide .slot[data-slot="subtitle"] {
font-size: clamp(1rem, 2vw, .5rem);
text-align: right;
width: 50%;
}
.layout-content-slide .slot[data-slot="content"] {
font-size: clamp(1rem, 2.5vw, 1.25rem);
flex: 1;
text-align: left;
width: 100%;
line-height: 1.6;
/* Ensure content doesn't get too wide */
max-width: 100%;
margin: 0 auto;
}
/* Two content blocks layout */
.layout-2-content-blocks,
.slide-container .layout-2-content-blocks {
justify-content: flex-start;
align-items: stretch;
/* Reset text alignment for two-column slides */
text-align: initial;
}
.layout-2-content-blocks .slot[data-slot="title"] {
font-size: clamp(1.5rem, 5vw, 2.5rem);
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--theme-primary);
text-align: center;
width: 100%;
}
.content-blocks-container {
display: grid;
@ -219,41 +139,17 @@
align-items: stretch;
width: 100%;
}
.layout-2-content-blocks .slot[data-slot="content1"],
.layout-2-content-blocks .slot[data-slot="content2"] {
.slot[data-slot="content"],
.slot[data-slot="content1"],
.slot[data-slot="content2"] {
font-size: clamp(0.9rem, 2.2vw, 1.1rem);
text-align: left;
width: 100%;
line-height: 1.6;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2rem;
color: var(--theme-text-secondary);
}
/* Image slide layout */
.layout-image-slide,
.slide-container .layout-image-slide {
justify-content: flex-start;
align-items: stretch;
}
.layout-image-slide .slot[data-slot="title"] {
font-size: clamp(1.5rem, 6vw, 2.5rem);
margin-bottom: 2rem;
text-align: center;
width: 100%;
}
.layout-image-slide .slot[data-slot="image"] {
flex: 1;
margin-top: 1rem;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* Aspect ratio specific adjustments */
.slide-container.aspect-16-9 .slide-content,
@ -281,20 +177,7 @@
--content-max-width: 95%;
}
.layout-title-slide .slot[data-slot="title"] {
margin-bottom: 1rem;
font-size: clamp(1.5rem, 6vw, 3rem);
}
.layout-content-slide .slot[data-slot="title"] {
margin-bottom: 1rem;
font-size: clamp(1.2rem, 5vw, 2rem);
}
.layout-image-slide .slot[data-slot="title"] {
font-size: clamp(1.2rem, 5vw, 2rem);
}
/* Two content blocks responsive adjustments */
.content-blocks-container {
grid-template-columns: 1fr;
@ -307,20 +190,274 @@
padding: 0.75rem;
}
}
.fade-in {
opacity: 1;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 1s;
}
/* Print styles for presentations */
@media print {
.slide {
page-break-after: always;
width: 100%;
height: 100vh;
.fade-in-slow {
opacity: 0;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 2s;
animation-delay: 1s;
animation-fill-mode: forwards;
}
@keyframes fadeInOpacity {
0% {
opacity: 0;
}
.slot {
border: none !important;
}
.slot.empty::before {
display: none;
100% {
opacity: 1;
}
}
/* Enhanced code blocks with syntax highlighting */
.slide-code {
background: #1e1e1e !important;
border: 1px solid var(--theme-secondary);
border-radius: 8px;
padding: 1.5rem;
margin: 1.5em 0;
overflow-x: auto;
font-family: var(--theme-font-code);
font-size: clamp(0.75rem, 1.5vw, 0.9rem);
line-height: 1.4;
position: relative;
}
.slide-code code {
background: none !important;
padding: 0 !important;
color: #e6e6e6;
font-family: inherit;
}
/* Prism.js syntax highlighting theme for slides */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #6a9955;
font-style: italic;
}
.token.punctuation {
color: #d4d4d4;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #b5cea8;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #ce9178;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #d4d4d4;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #569cd6;
}
.token.function,
.token.class-name {
color: #dcdcaa;
}
.token.regex,
.token.important,
.token.variable {
color: #d16969;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
/* Language labels for code blocks */
.slide-code::before {
content: attr(class);
position: absolute;
top: 0.5rem;
right: 1rem;
font-size: 0.75rem;
color: var(--theme-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
.slide-code.language-javascript::before { content: 'JavaScript'; }
.slide-code.language-typescript::before { content: 'TypeScript'; }
.slide-code.language-python::before { content: 'Python'; }
.slide-code.language-bash::before { content: 'Bash'; }
.slide-code.language-json::before { content: 'JSON'; }
.slide-code.language-css::before { content: 'CSS'; }
.slide-code.language-markup::before { content: 'HTML'; }
.slide-code.language-html::before { content: 'HTML'; }
.slide-code.language-sql::before { content: 'SQL'; }
.slide-code.language-yaml::before { content: 'YAML'; }
/* Mermaid diagram styling */
.mermaid-container {
margin: 2rem 0;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid var(--theme-secondary);
text-align: center;
overflow-x: auto;
}
.mermaid-diagram {
max-width: 100%;
height: auto;
}
.mermaid-diagram svg {
max-width: 100%;
height: auto;
background: transparent;
}
.mermaid-error {
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
padding: 1rem;
border-radius: 4px;
border: 1px solid #ff6b6b;
font-family: var(--theme-font-code);
font-size: 0.9rem;
}
/* Enhanced slide callouts */
.slide-callout {
background: rgba(149, 116, 235, 0.15);
border-left: 4px solid var(--theme-primary);
padding: 1rem 1.5rem;
margin: 1.5em 0;
border-radius: 0 8px 8px 0;
font-style: italic;
color: var(--theme-text);
position: relative;
}
.slide-callout::before {
content: '💡';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
background: var(--theme-background);
padding: 0.25rem;
border-radius: 50%;
font-style: normal;
}
/* Enhanced slide lists */
.slide-list {
margin: 1.5em 0;
padding-left: 2rem;
}
.slide-list li {
margin: 0.75em 0;
color: var(--theme-text);
line-height: 1.6;
position: relative;
}
.slide-list li::marker {
color: var(--theme-accent);
font-weight: 600;
}
/* Enhanced slide tables */
.slide-table-wrapper {
margin: 2rem 0;
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--theme-secondary);
}
.slide-table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.02);
}
.slide-table th,
.slide-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--theme-secondary);
}
.slide-table th {
background: rgba(149, 116, 235, 0.2);
font-weight: 600;
color: var(--theme-primary);
border-bottom: 2px solid var(--theme-primary);
}
.slide-table tr:nth-child(even) {
background: rgba(255, 255, 255, 0.03);
}
.slide-table tr:hover {
background: rgba(149, 116, 235, 0.1);
}
/* Responsive adjustments for enhanced features */
@media (max-width: 768px) {
.slide-code {
padding: 1rem;
font-size: 0.8rem;
}
.slide-code::before {
font-size: 0.7rem;
}
.mermaid-container {
padding: 0.5rem;
margin: 1rem 0;
}
.slide-callout {
padding: 0.75rem 1rem;
margin: 1rem 0;
}
}

View File

@ -1,13 +1,14 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect} from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { Presentation, SlideContent } from '../../types/presentation.ts';
import type { Theme } from '../../types/theme.ts';
import { getPresentationById } from '../../utils/presentationStorage.ts';
import { loadTheme } from '../../utils/themeLoader.ts';
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<{
@ -15,15 +16,13 @@ export const PresentationMode: React.FC = () => {
slideNumber: string;
}>();
const navigate = useNavigate();
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [currentSlideIndex, setCurrentSlideIndex] = useState(
slideNumber ? parseInt(slideNumber, 10) - 1 : 0
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 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;
@ -32,50 +31,16 @@ export const PresentationMode: React.FC = () => {
setCurrentSlideIndex(clampedIndex);
navigate(`/presentations/${presentationId}/present/${clampedIndex + 1}`, { replace: true });
};
// Keyboard navigation handler
const handleKeyPress = useCallback((event: KeyboardEvent) => {
if (!presentation || presentation.slides.length === 0) return;
switch (event.key) {
case 'ArrowRight':
case 'Space':
case 'Enter':
event.preventDefault();
goToSlide(currentSlideIndex + 1);
break;
case 'ArrowLeft':
event.preventDefault();
goToSlide(currentSlideIndex - 1);
break;
case 'Home':
event.preventDefault();
goToSlide(0);
break;
case 'End':
event.preventDefault();
goToSlide(presentation.slides.length - 1);
break;
case 'Escape':
event.preventDefault();
exitPresentationMode();
break;
default:
// Handle number keys for direct slide navigation
const slideNum = parseInt(event.key);
if (slideNum >= 1 && slideNum <= presentation.slides.length) {
event.preventDefault();
goToSlide(slideNum - 1);
}
break;
}
}, [presentation, currentSlideIndex, goToSlide]);
useKeyboardNavigation({
totalSlides: presentation?.slides.length || 0,
currentSlideIndex,
onNavigate: goToSlide
});
// Sync current slide index with URL parameter
useEffect(() => {
if (slideNumber) {
@ -85,81 +50,7 @@ export const PresentationMode: React.FC = () => {
}
}
}, [slideNumber]);
// Set up keyboard listeners
useEffect(() => {
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
// Load presentation and theme
useEffect(() => {
const loadPresentationAndTheme = async () => {
if (!presentationId) {
setError('No presentation ID provided');
setLoading(false);
return;
}
try {
setLoading(true);
// Load presentation
const presentationData = await getPresentationById(presentationId);
if (!presentationData) {
setError(`Presentation not found: ${presentationId}`);
return;
}
setPresentation(presentationData);
// Load theme
const themeData = await loadTheme(presentationData.metadata.theme, false);
if (!themeData) {
setError(`Theme not found: ${presentationData.metadata.theme}`);
return;
}
setTheme(themeData);
// Load theme CSS
const cssLink = document.getElementById('presentation-theme-css') as HTMLLinkElement;
const cssPath = `${themeData.basePath}/${themeData.cssFile}`;
if (cssLink) {
cssLink.href = cssPath;
} else {
const newCssLink = document.createElement('link');
newCssLink.id = 'presentation-theme-css';
newCssLink.rel = 'stylesheet';
newCssLink.href = cssPath;
document.head.appendChild(newCssLink);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentation');
} finally {
setLoading(false);
}
};
loadPresentationAndTheme();
// Cleanup theme CSS on unmount
return () => {
const cssLink = document.getElementById('presentation-theme-css');
if (cssLink) {
cssLink.remove();
}
};
}, [presentationId]);
const exitPresentationMode = () => {
// Navigate back to the previous page in history
navigate(-1);
};
const renderSlideContent = (slide: SlideContent): string => {
if (!theme) return '';
@ -174,7 +65,16 @@ export const PresentationMode: React.FC = () => {
// Replace template variables with slide content
Object.entries(slide.content).forEach(([slotId, content]) => {
const regex = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
renderedTemplate = renderedTemplate.replace(regex, content);
// 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
@ -197,7 +97,7 @@ export const PresentationMode: React.FC = () => {
<div className="error-content">
<h2>Error Loading Presentation</h2>
<p>{error}</p>
<button onClick={exitPresentationMode} className="exit-button">
<button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode
</button>
</div>
@ -210,7 +110,7 @@ export const PresentationMode: React.FC = () => {
<div className="presentation-mode error">
<div className="error-content">
<h2>Presentation Not Found</h2>
<button onClick={exitPresentationMode} className="exit-button">
<button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode
</button>
</div>
@ -224,7 +124,7 @@ export const PresentationMode: React.FC = () => {
<div className="error-content">
<h2>No Slides Available</h2>
<p>This presentation is empty.</p>
<button onClick={exitPresentationMode} className="exit-button">
<button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode
</button>
</div>

View File

@ -0,0 +1,71 @@
import { useCallback, useEffect } from 'react';
import {useNavigate} from "react-router-dom";
interface KeyboardNavigationOptions {
totalSlides: number;
currentSlideIndex: number;
onNavigate: (slideIndex: number) => void;
onExit?: () => void;
}
export function useKeyboardNavigation({
totalSlides,
currentSlideIndex,
onNavigate
}: KeyboardNavigationOptions) {
// Keyboard navigation handler
const navigate = useNavigate();
const handleKeyPress = useCallback((event: KeyboardEvent) => {
if (totalSlides === 0) return;
switch (event.key) {
case 'ArrowRight':
case 'Space':
case 'Enter':
event.preventDefault();
onNavigate(Math.min(currentSlideIndex + 1, totalSlides - 1));
break;
case 'ArrowLeft':
event.preventDefault();
onNavigate(Math.max(currentSlideIndex - 1, 0));
break;
case 'Home':
event.preventDefault();
onNavigate(0);
break;
case 'End':
event.preventDefault();
onNavigate(totalSlides - 1);
break;
case 'Escape':
event.preventDefault();
navigate(-1);
break;
default:
// Handle number keys for direct slide navigation
const slideNum = parseInt(event.key);
if (slideNum >= 1 && slideNum <= totalSlides) {
event.preventDefault();
onNavigate(slideNum - 1);
}
break;
}
}, [totalSlides, currentSlideIndex, onNavigate]);
// Set up keyboard listeners
useEffect(() => {
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
return { handleKeyPress };
}

View File

@ -0,0 +1,75 @@
import { useState, useEffect } from 'react';
import type {Presentation} from "../../../types/presentation.ts";
import {getPresentationById} from "../../../utils/presentationStorage.ts";
import type {Theme} from "../../../types/theme.ts";
import {loadTheme} from "../../../utils/themeLoader.ts";
export function usePresentationLoader(presentationId: string | undefined) {
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadPresentationAndTheme = async () => {
if (!presentationId) {
setError('No presentation ID provided');
setLoading(false);
return;
}
try {
setLoading(true);
// Load presentation
const presentationData = await getPresentationById(presentationId);
if (!presentationData) {
setError(`Presentation not found: ${presentationId}`);
return;
}
setPresentation(presentationData);
// Load theme
const themeData = await loadTheme(presentationData.metadata.theme, false);
if (!themeData) {
setError(`Theme not found: ${presentationData.metadata.theme}`);
return;
}
setTheme(themeData);
// Load theme CSS
const cssLink = document.getElementById('presentation-theme-css') as HTMLLinkElement;
const cssPath = `${themeData.basePath}/${themeData.cssFile}`;
if (cssLink) {
cssLink.href = cssPath;
} else {
const newCssLink = document.createElement('link');
newCssLink.id = 'presentation-theme-css';
newCssLink.rel = 'stylesheet';
newCssLink.href = cssPath;
document.head.appendChild(newCssLink);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentation');
} finally {
setLoading(false);
}
};
loadPresentationAndTheme();
// Cleanup theme CSS on unmount
return () => {
const cssLink = document.getElementById('presentation-theme-css');
if (cssLink) {
cssLink.remove();
}
};
}, [presentationId]);
return { presentation, theme, loading, error };
}

View File

@ -45,15 +45,17 @@ export const ContentEditor: React.FC<ContentEditorProps> = ({
);
}
if (slot.type === 'text' && slot.id.includes('content')) {
if (slot.type === 'markdown' || (slot.type === 'text' && slot.id.includes('content'))) {
return (
<textarea
id={slot.id}
value={slideContent[slot.id] || ''}
onChange={(e) => onSlotContentChange(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
className="field-textarea"
rows={4}
placeholder={slot.type === 'markdown'
? slot.placeholder || 'Enter markdown content (e.g., **bold**, *italic*, - list items)'
: slot.placeholder || `Enter ${slot.id}`}
className={`field-textarea ${slot.type === 'markdown' ? 'markdown-field' : ''}`}
rows={slot.type === 'markdown' ? 6 : 4}
/>
);
}

View File

@ -1,4 +1,5 @@
import type { SlideLayout } from '../../types/theme.ts';
import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts';
// Helper function to render template with actual content
export const renderTemplateWithContent = (layout: SlideLayout, content: Record<string, string>): string => {
@ -7,7 +8,16 @@ export const renderTemplateWithContent = (layout: SlideLayout, content: Record<s
// Replace content placeholders
Object.entries(content).forEach(([slotId, value]) => {
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
rendered = rendered.replace(placeholder, value || '');
// 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(value || ''));
const processedValue = shouldProcessAsMarkdown ?
renderSlideMarkdown(value || '', slot?.type) : (value || '');
rendered = rendered.replace(placeholder, processedValue);
});
// Clean up any remaining placeholders

View File

@ -0,0 +1,194 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
/**
* Secure markdown processor for slide content
* Uses marked for parsing and DOMPurify for sanitization
*/
// Configure marked for slide-safe markdown
marked.setOptions({
// Disable HTML rendering for security
sanitize: false, // We'll handle sanitization with DOMPurify
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert line breaks to <br>
headerIds: false, // Don't generate header IDs
mangle: false, // Don't mangle email addresses
});
/**
* DOMPurify configuration for slides
* Allows safe HTML elements commonly used in presentations
*/
const PURIFY_CONFIG = {
ALLOWED_TAGS: [
// Text formatting
'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's', 'mark', 'small', 'sub', 'sup',
// Headers
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Lists
'ul', 'ol', 'li',
// Links (without javascript: or data: schemes)
'a',
// Code
'code', 'pre',
// Tables
'table', 'thead', 'tbody', 'tr', 'th', 'td',
// Quotes
'blockquote', 'cite',
// Semantic elements
'span', 'div',
],
ALLOWED_ATTR: [
'href', 'title', 'class', 'id', 'data-*',
// Table attributes
'colspan', 'rowspan',
// Link attributes (but we'll filter URLs)
'target', 'rel'
],
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'applet', 'form', 'input', 'button'],
FORBID_ATTR: ['onclick', 'onload', 'onerror', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
KEEP_CONTENT: true,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
SANITIZE_DOM: true,
};
/**
* Safely renders markdown to HTML
* @param markdown - The markdown content to render
* @param options - Additional rendering options
* @returns Sanitized HTML string
*/
export const renderMarkdown = (
markdown: string,
options: {
allowImages?: boolean;
maxHeadingLevel?: number;
className?: string;
} = {}
): string => {
if (!markdown || typeof markdown !== 'string') {
return '';
}
try {
// Parse markdown to HTML
let html = marked.parse(markdown) as string;
// Apply heading level restrictions
if (options.maxHeadingLevel && options.maxHeadingLevel < 6) {
for (let level = options.maxHeadingLevel + 1; level <= 6; level++) {
const regex = new RegExp(`<h${level}([^>]*)>`, 'g');
html = html.replace(regex, `<h${options.maxHeadingLevel}$1>`);
html = html.replace(new RegExp(`</h${level}>`, 'g'), `</h${options.maxHeadingLevel}>`);
}
}
// Configure DOMPurify for this render
const config = { ...PURIFY_CONFIG };
// Conditionally allow images
if (options.allowImages) {
config.ALLOWED_TAGS = [...config.ALLOWED_TAGS, 'img'];
config.ALLOWED_ATTR = [...config.ALLOWED_ATTR, 'src', 'alt', 'width', 'height'];
}
// Sanitize the HTML
const sanitized = DOMPurify.sanitize(html, config);
// Wrap in container if className provided
if (options.className) {
return `<div class="${options.className}">${sanitized}</div>`;
}
return sanitized;
} catch (error) {
console.warn('Markdown rendering failed:', error);
// Return escaped plain text as fallback
return DOMPurify.sanitize(markdown.replace(/[<>&"']/g, (char) => {
const escapeMap: Record<string, string> = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&#x27;'
};
return escapeMap[char];
}));
}
};
/**
* Renders markdown specifically for slide content
* Applies slide-appropriate restrictions and styling
*/
export const renderSlideMarkdown = (markdown: string, slotType?: string): string => {
const options = {
allowImages: slotType === 'image' || slotType === 'content',
maxHeadingLevel: 3, // Limit to h1, h2, h3 for slides
className: `markdown-content slot-${slotType || 'text'}`
};
return renderMarkdown(markdown, options);
};
/**
* Checks if content appears to be markdown
* Used to determine whether to process content as markdown
*/
export const isMarkdownContent = (content: string): boolean => {
if (!content || typeof content !== 'string') {
return false;
}
// Common markdown patterns
const markdownPatterns = [
/^#{1,6}\s/m, // Headers
/\*\*.*\*\*/, // Bold
/\*.*\*/, // Italic
/^\s*[-*+]\s/m, // Unordered lists
/^\s*\d+\.\s/m, // Ordered lists
/```/, // Code blocks
/`[^`]+`/, // Inline code
/\[.*\]\(.*\)/, // Links
/!\[.*\]\(.*\)/, // Images
/^>\s/m, // Blockquotes
];
return markdownPatterns.some(pattern => pattern.test(content));
};
/**
* Sample markdown content for testing and previews
*/
export const SAMPLE_MARKDOWN_CONTENT = {
title: '# Quarterly **Sales** Review',
subtitle: '## Q4 Results and *Future Outlook*',
content: `
## Key Highlights
- **Revenue Growth**: 25% increase over last quarter
- **Customer Satisfaction**: 94% positive feedback
- **Market Expansion**: 3 new regions launched
### Action Items
1. Finalize Q1 budget allocation
2. Launch customer feedback program
3. Prepare expansion strategy
> "Our team's dedication to excellence continues to drive exceptional results."
[View Full Report](https://example.com/report)
`.trim(),
list: `
- Increase market share by **15%**
- Launch *3 new products*
- Expand to **5 new regions**
- Improve customer satisfaction
- \`Optimize\` operational efficiency
`.trim()
};

View File

@ -1,4 +1,5 @@
import type { SlideLayout, SlotConfig } from '../types/theme.ts';
import { renderSlideMarkdown, isMarkdownContent, SAMPLE_MARKDOWN_CONTENT } from './markdownProcessor.ts';
/**
* Creates a simple SVG pattern for image slots
@ -59,6 +60,13 @@ const SAMPLE_CONTENT = {
'• Increase market share by 15%\n• Launch 3 new products\n• Expand to 5 new regions',
'• Improve customer satisfaction\n• Reduce operational costs\n• Enhance digital capabilities',
'• Strengthen brand recognition\n• Optimize supply chain\n• Invest in talent development'
],
markdown: [
SAMPLE_MARKDOWN_CONTENT.content,
SAMPLE_MARKDOWN_CONTENT.list,
SAMPLE_MARKDOWN_CONTENT.codeExample,
SAMPLE_MARKDOWN_CONTENT.diagramExample,
'## Key Points\n\n- **Important**: Focus on *customer needs*\n- Use `data-driven` decisions\n- > Success comes from teamwork'
]
};
@ -118,6 +126,11 @@ export const generateSampleDataForSlot = (slot: SlotConfig): string => {
case 'table':
return `${slotDisplayName} Col 1 | ${slotDisplayName} Col 2\n--- | ---\nRow 1 Data | Row 1 Data\nRow 2 Data | Row 2 Data`;
case 'markdown': {
const markdownSamples = SAMPLE_CONTENT.markdown;
return markdownSamples[Math.floor(Math.random() * markdownSamples.length)];
}
default:
return slotDisplayName;
}
@ -163,7 +176,16 @@ export const renderTemplateWithSampleData = (
// Handle Handlebars-style templates: {{variable}}
Object.entries(sampleData).forEach(([key, value]) => {
const simpleRegex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
rendered = rendered.replace(simpleRegex, value);
// Find the corresponding slot to determine if it should be processed as markdown
const slot = layout.slots.find(s => s.id === key);
const shouldProcessAsMarkdown = slot?.type === 'markdown' ||
(slot?.type === 'text' && isMarkdownContent(value));
const processedValue = shouldProcessAsMarkdown ?
renderSlideMarkdown(value, slot?.type) : value;
rendered = rendered.replace(simpleRegex, processedValue);
});
// Handle conditional blocks: {{#variable}}...{{/variable}}