Skip to content

[Feat] Markdown Slide Deck #120

Description

@FumingPower3925

Description

Write markdown with `---` separators between slides, get a navigable fullscreen presentation. Arrow key navigation, slide counter, speaker notes, syntax-highlighted code blocks, and theming. Like a mini Marp/Slidev in the browser — no install, no server, no account. Export as PDF via `window.print()`.

Features

Slide Authoring

  • `---` separators — Each `---` on its own line starts a new slide
  • Standard Markdown — Full GFM support: headings, lists, bold/italic, links, images, tables, blockquotes
  • Code blocks — Syntax-highlighted with language detection (reuse tokenizer from [Feat] Code Screenshot Generator #112 or `marked` from [Feat] Tool: Markdown to PDF Converter #54)
  • Speaker notes — Content after `???` on a slide is hidden from the presentation, shown in presenter view
  • Slide-level directives — HTML comments for per-slide config:
    • `` — Center all content
    • `` — Custom background color
    • `` — Background image
    • `` — Slide transition
  • Images — Centered by default, `bg` for background images
  • Math — Basic LaTeX math rendering for inline `$...$` and block `$$...$$` (lightweight custom renderer for common symbols)
  • Incremental lists — `> -` prefix for items that appear one-by-one on keypress

Presentation Mode

  • Fullscreen — F11 or button to enter fullscreen presentation
  • Navigation:
    • Arrow keys (left/right) or space/backspace
    • Click left/right edges of the slide
    • Touch swipe on mobile
    • Page up/down
  • Slide counter — "3 / 15" indicator (hideable)
  • Progress bar — Thin bar at bottom showing position
  • Presenter view — Open in a second window/tab:
    • Current slide + next slide preview
    • Speaker notes for current slide
    • Timer (elapsed time, optional countdown)
    • Slide navigator grid
  • Keyboard shortcuts:
    • `F` — Toggle fullscreen
    • `P` — Open presenter view
    • `G` — Go to slide number
    • `B` — Black screen (pause)
    • `O` — Overview / grid view of all slides
    • `Esc` — Exit presentation mode

Themes

  • Built-in themes:
    • Default (clean, white background, dark text)
    • Dark (dark background, light text)
    • Corporate (navy + white, serif headings)
    • Minimal (lots of whitespace, sans-serif)
    • Hacker (black + green terminal style)
    • Academic (LaTeX-inspired, serif fonts)
  • Custom CSS — Override any theme via a CSS block at the top of the document
  • Consistent sizing — All slides 16:9 aspect ratio, scaled to fit viewport

Transitions

  • Slide transitions: none (instant), fade, slide-left, slide-right, slide-up, zoom
  • Per-slide override via directive

Export

  • PDF — `window.print()` with print CSS that renders each slide as a page (16:9 landscape)
  • HTML — Self-contained single HTML file with all slides embedded
  • PNG — Export individual slides as images via Canvas
  • Markdown — The source markdown itself (with a "copy source" button)

Implementation

Dependencies

Package License Gzipped Purpose
`marked` MIT ~12 KB Markdown parsing (shared with #54)

The `marked` library is already planned for the Markdown to PDF tool (#54). Reusing it here avoids duplicate dependencies. Syntax highlighting uses either the custom tokenizer from #112 or `highlight.js` from #54.

Core Logic

interface Slide {
    content: string;          // Markdown content
    notes: string;            // Speaker notes (after ???)
    directives: SlideDirectives;
    html?: string;            // Rendered HTML (cached)
}

interface SlideDirectives {
    class?: string;
    background?: string;
    backgroundImage?: string;
    transition?: string;
}

function parseSlides(markdown: string): Slide[] {
    const rawSlides = markdown.split(/\n---\n/);
    
    return rawSlides.map(raw => {
        // Split content and notes
        const [content, ...notesParts] = raw.split(/\n\?\?\?\n/);
        const notes = notesParts.join('\n').trim();
        
        // Extract directives from HTML comments
        const directives: SlideDirectives = {};
        const directivePattern = /<!--\s*(\w+):\s*(.+?)\s*-->/g;
        let match;
        while ((match = directivePattern.exec(content)) !== null) {
            directives[match[1] as keyof SlideDirectives] = match[2];
        }
        
        // Clean content (remove directive comments)
        const cleanContent = content.replace(directivePattern, '').trim();
        
        return { content: cleanContent, notes, directives };
    });
}

function renderSlide(slide: Slide, theme: Theme): HTMLElement {
    const el = document.createElement('div');
    el.className = `slide ${slide.directives.class ?? ''}`;
    
    // Apply background
    if (slide.directives.background) {
        el.style.backgroundColor = slide.directives.background;
    }
    if (slide.directives.backgroundImage) {
        el.style.backgroundImage = `url(${slide.directives.backgroundImage})`;
        el.style.backgroundSize = 'cover';
        el.style.backgroundPosition = 'center';
    }
    
    // Render markdown to HTML
    el.innerHTML = marked.parse(slide.content);
    
    // Apply theme styles
    applyTheme(el, theme);
    
    return el;
}

// Presentation controller
class PresentationController {
    private slides: Slide[];
    private currentIndex: number = 0;
    private container: HTMLElement;
    private presenterWindow: Window | null = null;
    
    navigate(direction: 'next' | 'prev' | number): void {
        const target = typeof direction === 'number'
            ? direction
            : direction === 'next'
                ? Math.min(this.currentIndex + 1, this.slides.length - 1)
                : Math.max(this.currentIndex - 1, 0);
        
        if (target === this.currentIndex) return;
        
        const transition = this.slides[target].directives.transition ?? 'fade';
        this.transitionTo(target, transition);
        this.currentIndex = target;
        this.updatePresenterView();
    }
    
    transitionTo(index: number, type: string): void {
        const current = this.container.querySelector('.slide.active') as HTMLElement;
        const next = renderSlide(this.slides[index], this.theme);
        next.classList.add('slide', 'incoming');
        this.container.appendChild(next);
        
        // CSS transition
        requestAnimationFrame(() => {
            current?.classList.add(`exit-${type}`);
            next.classList.remove('incoming');
            next.classList.add('active', `enter-${type}`);
            
            current?.addEventListener('transitionend', () => current.remove(), { once: true });
        });
    }
    
    openPresenterView(): void {
        this.presenterWindow = window.open('', 'presenter', 'width=800,height=600');
        this.updatePresenterView();
    }
    
    private updatePresenterView(): void {
        if (!this.presenterWindow) return;
        const slide = this.slides[this.currentIndex];
        const nextSlide = this.slides[this.currentIndex + 1];
        
        this.presenterWindow.document.body.innerHTML = `
            <div class="presenter-layout">
                <div class="current-slide">${marked.parse(slide.content)}</div>
                <div class="next-slide">${nextSlide ? marked.parse(nextSlide.content) : '<p>End</p>'}</div>
                <div class="notes">${marked.parse(slide.notes || '*No notes*')}</div>
                <div class="counter">${this.currentIndex + 1} / ${this.slides.length}</div>
                <div class="timer" id="timer">00:00</div>
            </div>
        `;
    }
}

Slide CSS (16:9 Aspect Ratio)

.slide-container {
    position: relative;
    width: 100vw;
    height: 100vh;
    overflow: hidden;
}

.slide {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 5% 8%;
    aspect-ratio: 16 / 9;
    /* Scale to fit viewport while maintaining 16:9 */
    max-width: min(100vw, 100vh * 16 / 9);
    max-height: min(100vh, 100vw * 9 / 16);
    margin: auto;
    font-size: clamp(16px, 2.5vw, 32px);
}

.slide h1 { font-size: 2.5em; margin-bottom: 0.5em; }
.slide h2 { font-size: 1.8em; margin-bottom: 0.4em; }
.slide ul, .slide ol { font-size: 1.1em; line-height: 1.8; }
.slide code { font-size: 0.85em; }
.slide pre { font-size: 0.75em; padding: 1em; border-radius: 8px; }
.slide.center { text-align: center; align-items: center; }

/* Transitions */
.slide.exit-fade { opacity: 0; transition: opacity 0.4s; }
.slide.enter-fade { animation: fadeIn 0.4s; }
.slide.exit-slide-left { transform: translateX(-100%); transition: transform 0.4s; }
.slide.enter-slide-left { animation: slideFromRight 0.4s; }

@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideFromRight { from { transform: translateX(100%); } to { transform: translateX(0); } }

/* Print: each slide = one page */
@media print {
    .slide { page-break-after: always; width: 100%; height: 100vh; }
    .slide-counter, .progress-bar, .controls { display: none; }
}

Privacy

All rendering client-side. Presentation content (which may contain confidential information) never leaves the browser.

UI

  • Editor mode (default):
    • Left: Markdown editor with line numbers and slide separators highlighted
    • Right: Live slide preview (current slide rendered)
    • Bottom: Slide strip (thumbnails of all slides, click to jump)
    • Toolbar: Theme selector, Present button, Export dropdown
  • Presentation mode (fullscreen):
    • Single slide filling the screen
    • Slide counter (bottom-right, subtle)
    • Progress bar (bottom, thin line)
    • Invisible navigation zones (left/right click areas)
  • Presenter view (separate window):
    • Current slide, next slide preview, speaker notes, timer, slide counter
  • Overview mode (O key):
    • Grid of all slide thumbnails, click to jump
  • Responsive editor layout

Files to Create/Modify

File Purpose
`tools/slide-deck/package.json` Workspace package, `marked` dependency (shared with #54)
`tools/slide-deck/src/parser.ts` Slide parsing, directive extraction, notes separation
`tools/slide-deck/src/renderer.ts` Slide HTML rendering, theme application
`tools/slide-deck/src/presenter.ts` PresentationController, navigation, transitions, presenter view
`tools/slide-deck/src/themes.ts` Theme definitions (6 built-in themes)
`tools/slide-deck/src/tool.ts` Public API
`tools/slide-deck/src/page.ts` Editor layout, slide strip, toolbar
`tools/slide-deck/src/meta.ts` ToolMeta
`tools/slide-deck/src/index.ts` Exports
`tools/slide-deck/tests/parser.unit.test.ts` Slide splitting, directives, notes extraction
`tools/slide-deck/tests/presenter.unit.test.ts` Navigation logic, slide transitions
`tools/slide-deck/tests/page.unit.test.ts` DOM rendering tests
`src/index.ts` Register route
`src/index.html` Add to tool grid

Metadata

Metadata

Assignees

No one assigned

    Labels

    batch/8-flagshipComplex, flagship tools (diagrams, presentations, editors)enhancementNew feature or request

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions