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" "generate-manifest": "node scripts/generate-themes-manifest.js"
}, },
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.5",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"marked": "^16.2.0",
"mermaid": "^11.10.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.8.1" "react-router-dom": "^7.8.1"

View File

@ -5,12 +5,14 @@
"cssFile": "style.css", "cssFile": "style.css",
"layouts": [ "layouts": [
"2-content-blocks", "2-content-blocks",
"code-slide",
"content-slide", "content-slide",
"image-slide", "image-slide",
"markdown-slide",
"title-slide" "title-slide"
], ],
"hasMasterSlide": true "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"` - **Text content**: `data-slot="content"`
- **Images**: `data-slot="image"` with `data-accept="image/*"` - **Images**: `data-slot="image"` with `data-accept="image/*"`
- **Subtitles**: `data-slot="subtitle"` - **Subtitles**: `data-slot="subtitle"`
- **Markdown content**: `data-slot="content" data-type="markdown"`
### Layout CSS Naming Convention ### Layout CSS Naming Convention
Style layouts using the pattern: `.layout-[layout-name]` 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 ## Best Practices
1. **Use clamp() for responsive typography**: `font-size: clamp(min, preferred, max)` 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 5. **Consider accessibility**: Use sufficient color contrast and readable fonts
6. **Use semantic HTML**: Proper heading hierarchy and meaningful class names 6. **Use semantic HTML**: Proper heading hierarchy and meaningful class names
7. **Keep layouts flexible**: Use flexbox/grid for responsive behavior 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) ## Template Variables (Handlebars)

View File

@ -1,4 +1,4 @@
<div class="slide layout-2-content-blocks"> <div class="fade-in slide layout-2-content-blocks">
<h1 class=" slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required> <h1 class=" slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}} {{title}}
</h1> </h1>
@ -7,7 +7,10 @@
{{content1}} {{content1}}
</div> </div>
<div class="slot content-slot" data-slot="content2" data-placeholder="Second content block" data-required> <div class="slot content-slot" data-slot="content2" data-placeholder="Second content block" data-required>
<span class="fade-in-slow">
{{content2}} {{content2}}
</span>
</div> </div>
</div> </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> <h1 class="slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}} {{title}}
</h1> </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"> <div class="slot content-area" data-slot="content" data-placeholder="Your content here..." data-multiline="true">
{{content}} {{content}}
</div> </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> <h1 class="slot title-slot" data-slot="title" data-placeholder="Slide Title" data-required>
{{title}} {{title}}
</h1> </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> <h1 class="slot title-slot" data-slot="title" data-placeholder="Presentation Title" data-required>
{{title}} {{title}}
</h1> </h1>
<div class="slot diagram-slot" data-slot="diagram" data-placeholder="Diagram or Image" data-required>
{{diagram}}
</div>
</div> </div>

View File

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

View File

@ -27,7 +27,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
background: var(--theme-background); background: var(--theme-background);
color: var(--theme-text); color: var(--theme-primary);
font-family: var(--theme-font-body); font-family: var(--theme-font-body);
padding: var(--slide-padding); padding: var(--slide-padding);
box-sizing: border-box; box-sizing: border-box;
@ -35,8 +35,6 @@
flex-direction: column; flex-direction: column;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
justify-content: center;
align-items: center;
/* Ensure content is properly centered within container */ /* Ensure content is properly centered within container */
text-align: center; text-align: center;
} }
@ -69,10 +67,13 @@
/* Ensure slot content inherits proper centering */ /* Ensure slot content inherits proper centering */
text-align: inherit; text-align: inherit;
} }
#main-image {
scale: .7; .slot[data-slot="image"] {
width: 100%;
}
.slot[data-slot="image"] img {
width: 100%;
} }
.slot:hover,
.slot.editing { .slot.editing {
border-color: var(--theme-accent); border-color: var(--theme-accent);
border-radius: 4px; border-radius: 4px;
@ -92,20 +93,6 @@
font-style: italic; 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 { .content-slot {
color: var(--theme-text-secondary); color: var(--theme-text-secondary);
background-color: var(--theme-background); background-color: var(--theme-background);
@ -115,101 +102,34 @@
/* Text content can be left-aligned for readability */ /* Text content can be left-aligned for readability */
text-align: left; text-align: left;
} }
/* Image slots */
.slot[data-type="image"] { .slot[data-type="image"] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f8fafc; background: #fffffd;
border-radius: 8px; border-radius: 8px;
} }
.slot[data-type="image"] img { .slot[data-type="image"] img {
max-width: 100%; max-width: 100px;
max-height: 100%; max-height: 100px;
object-fit: contain;
border-radius: 4px; border-radius: 4px;
} }
/* Layout-specific styles */ .slot[data-slot="title"] {
font-size: clamp(5rem, 8vw, 4rem);
/* 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);
margin-bottom: 2rem; margin-bottom: 2rem;
width: 100%; width: 80%;
max-width: 80%;
color: var(--theme-primary);
text-align: center; text-align: center;
} }
.layout-title-slide .slot[data-slot="subtitle"] { .slot[data-slot="subtitle"] {
font-size: clamp(1rem, 4vw, 2rem); font-size: clamp(1rem, 4vw, 2rem);
color: var(--theme-text-secondary); color: var(--theme-text-secondary);
width: 100%; width: 100%;
max-width: 80%; max-width: 80%;
text-align: center; 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 { .content-blocks-container {
display: grid; display: grid;
@ -219,41 +139,17 @@
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
} }
.slot[data-slot="content"],
.layout-2-content-blocks .slot[data-slot="content1"], .slot[data-slot="content1"],
.layout-2-content-blocks .slot[data-slot="content2"] { .slot[data-slot="content2"] {
font-size: clamp(0.9rem, 2.2vw, 1.1rem); font-size: clamp(0.9rem, 2.2vw, 1.1rem);
text-align: left; text-align: left;
width: 100%; width: 100%;
line-height: 1.6; line-height: 1.6;
padding: 1rem; padding: 2rem;
background: rgba(255, 255, 255, 0.05); color: var(--theme-text-secondary);
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
} }
/* 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 */ /* Aspect ratio specific adjustments */
.slide-container.aspect-16-9 .slide-content, .slide-container.aspect-16-9 .slide-content,
@ -281,19 +177,6 @@
--content-max-width: 95%; --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 */ /* Two content blocks responsive adjustments */
.content-blocks-container { .content-blocks-container {
@ -307,20 +190,274 @@
padding: 0.75rem; 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 */ .fade-in-slow {
@media print { opacity: 0;
.slide { animation-name: fadeInOpacity;
page-break-after: always; animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 2s;
animation-delay: 1s;
animation-fill-mode: forwards;
}
@keyframes fadeInOpacity {
0% {
opacity: 0;
}
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%; width: 100%;
height: 100vh; border-collapse: collapse;
background: rgba(255, 255, 255, 0.02);
} }
.slot { .slide-table th,
border: none !important; .slide-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--theme-secondary);
} }
.slot.empty::before { .slide-table th {
display: none; 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 { 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 { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts'; import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts';
import { loggers } from '../../utils/logger.ts'; import { loggers } from '../../utils/logger.ts';
import './PresentationMode.css'; 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 = () => { export const PresentationMode: React.FC = () => {
const { presentationId, slideNumber } = useParams<{ const { presentationId, slideNumber } = useParams<{
@ -16,14 +17,12 @@ export const PresentationMode: React.FC = () => {
}>(); }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [currentSlideIndex, setCurrentSlideIndex] = useState( const [currentSlideIndex, setCurrentSlideIndex] = useState(
slideNumber ? parseInt(slideNumber, 10) - 1 : 0 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 // Navigate to specific slide and update URL
const goToSlide = (slideIndex: number) => { const goToSlide = (slideIndex: number) => {
if (!presentation) return; if (!presentation) return;
@ -33,48 +32,14 @@ export const PresentationMode: React.FC = () => {
navigate(`/presentations/${presentationId}/present/${clampedIndex + 1}`, { replace: true }); navigate(`/presentations/${presentationId}/present/${clampedIndex + 1}`, { replace: true });
}; };
// Keyboard navigation handler // Keyboard navigation handler
const handleKeyPress = useCallback((event: KeyboardEvent) => {
if (!presentation || presentation.slides.length === 0) return;
switch (event.key) { useKeyboardNavigation({
case 'ArrowRight': totalSlides: presentation?.slides.length || 0,
case 'Space': currentSlideIndex,
case 'Enter': onNavigate: goToSlide
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]);
// Sync current slide index with URL parameter // Sync current slide index with URL parameter
useEffect(() => { useEffect(() => {
@ -86,80 +51,6 @@ export const PresentationMode: React.FC = () => {
} }
}, [slideNumber]); }, [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 => { const renderSlideContent = (slide: SlideContent): string => {
if (!theme) return ''; if (!theme) return '';
@ -174,7 +65,16 @@ export const PresentationMode: React.FC = () => {
// Replace template variables with slide content // Replace template variables with slide content
Object.entries(slide.content).forEach(([slotId, content]) => { Object.entries(slide.content).forEach(([slotId, content]) => {
const regex = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g'); 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 // Handle conditional blocks and clean up remaining variables
@ -197,7 +97,7 @@ export const PresentationMode: React.FC = () => {
<div className="error-content"> <div className="error-content">
<h2>Error Loading Presentation</h2> <h2>Error Loading Presentation</h2>
<p>{error}</p> <p>{error}</p>
<button onClick={exitPresentationMode} className="exit-button"> <button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode Exit Presentation Mode
</button> </button>
</div> </div>
@ -210,7 +110,7 @@ export const PresentationMode: React.FC = () => {
<div className="presentation-mode error"> <div className="presentation-mode error">
<div className="error-content"> <div className="error-content">
<h2>Presentation Not Found</h2> <h2>Presentation Not Found</h2>
<button onClick={exitPresentationMode} className="exit-button"> <button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode Exit Presentation Mode
</button> </button>
</div> </div>
@ -224,7 +124,7 @@ export const PresentationMode: React.FC = () => {
<div className="error-content"> <div className="error-content">
<h2>No Slides Available</h2> <h2>No Slides Available</h2>
<p>This presentation is empty.</p> <p>This presentation is empty.</p>
<button onClick={exitPresentationMode} className="exit-button"> <button onClick={() => navigate(-1)} className="exit-button">
Exit Presentation Mode Exit Presentation Mode
</button> </button>
</div> </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 ( return (
<textarea <textarea
id={slot.id} id={slot.id}
value={slideContent[slot.id] || ''} value={slideContent[slot.id] || ''}
onChange={(e) => onSlotContentChange(slot.id, e.target.value)} onChange={(e) => onSlotContentChange(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`} placeholder={slot.type === 'markdown'
className="field-textarea" ? slot.placeholder || 'Enter markdown content (e.g., **bold**, *italic*, - list items)'
rows={4} : 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 type { SlideLayout } from '../../types/theme.ts';
import { renderSlideMarkdown, isMarkdownContent } from '../../utils/markdownProcessor.ts';
// Helper function to render template with actual content // Helper function to render template with actual content
export const renderTemplateWithContent = (layout: SlideLayout, content: Record<string, string>): string => { 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 // Replace content placeholders
Object.entries(content).forEach(([slotId, value]) => { Object.entries(content).forEach(([slotId, value]) => {
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g'); 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 // 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 type { SlideLayout, SlotConfig } from '../types/theme.ts';
import { renderSlideMarkdown, isMarkdownContent, SAMPLE_MARKDOWN_CONTENT } from './markdownProcessor.ts';
/** /**
* Creates a simple SVG pattern for image slots * 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', '• 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', '• Improve customer satisfaction\n• Reduce operational costs\n• Enhance digital capabilities',
'• Strengthen brand recognition\n• Optimize supply chain\n• Invest in talent development' '• 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': case 'table':
return `${slotDisplayName} Col 1 | ${slotDisplayName} Col 2\n--- | ---\nRow 1 Data | Row 1 Data\nRow 2 Data | Row 2 Data`; 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: default:
return slotDisplayName; return slotDisplayName;
} }
@ -163,7 +176,16 @@ export const renderTemplateWithSampleData = (
// Handle Handlebars-style templates: {{variable}} // Handle Handlebars-style templates: {{variable}}
Object.entries(sampleData).forEach(([key, value]) => { Object.entries(sampleData).forEach(([key, value]) => {
const simpleRegex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); 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}} // Handle conditional blocks: {{#variable}}...{{/variable}}