Building a Real-Time Markdown Editor with Live Preview and Image Upload

Title: Building a Real-Time Markdown Blog Editor with Live Preview: A Full-Stack Tutorial

Slug: building-markdown-blog-editor-live-preview-image-upload

Excerpt: Learn how I built a production-ready markdown editor with synchronized scrolling, live preview, and drag-and-drop image uploads for my blog system. Complete with code examples and implementation details.

Meta Description: Step-by-step guide to building a markdown blog editor with live preview, synchronized scrolling, and image upload. Includes PHP backend, JavaScript frontend, and WebP conversion.

Tags: Markdown Editor, Web Development, PHP, JavaScript, Blog CMS, Image Upload, Live Preview, Full-Stack Development, WebP Optimization, Content Management

Category: Tutorial

Read Time: 12 min read


Introduction

When building my personal blog at YourDev.net, I wanted a content management system that was both powerful and pleasant to use. WYSIWYG editors often generate bloated HTML, and plain text editors lack visual feedback. The solution? A custom markdown editor with real-time preview, synchronized scrolling, and seamless image uploads.

In this article, I'll walk you through exactly how I built this production-ready markdown editor from scratch, including the challenges I faced and how I solved them.

Why Markdown for a Blog?

Before diving into the code, let me explain why I chose markdown over other formats:

Clean Content Storage

  • Markdown stores as plain text in the database
  • Easy to version control and migrate
  • No vendor lock-in or proprietary formats
  • Can generate consistent HTML output

Developer-Friendly Writing

  • Familiar syntax for developers
  • Fast to write without lifting hands from keyboard
  • Syntax highlighting for code blocks
  • Natural structure with headers and lists

Performance Benefits

  • Smaller database storage compared to HTML
  • Client-side or server-side rendering options
  • Easy to cache and optimize
  • Portable across different rendering engines

System Architecture

The editor consists of three main components:

1. Frontend Editor (JavaScript + marked.js)

  • Live markdown rendering
  • Syntax highlighting with highlight.js
  • Synchronized scrolling between editor and preview
  • Image upload interface

2. Backend Processing (PHP)

  • File upload handling
  • Image format conversion to WebP
  • Database storage (both markdown and rendered HTML)
  • Security validation

3. Styling Layer (CSS)

  • Split-pane layout with flexbox
  • Viewport-height responsive panels
  • Status messages for upload feedback
  • Mobile-responsive design

Building the Editor Interface

HTML Structure

The editor uses a two-panel layout - one for writing markdown, one for live preview:

<div class="markdown-editor-container">
    <!-- Left Panel: Editor -->
    <div class="markdown-editor-panel">
        <div class="markdown-editor-header">
            <span>✏️ Markdown Editor</span>
            <div class="markdown-toolbar">
                <button onclick="insertMarkdown('**', '**')">B</button>
                <button onclick="insertMarkdown('*', '*')">I</button>
                <button onclick="insertMarkdown('## ', '')">H2</button>
                <button onclick="insertMarkdown('`', '`')">Code</button>
                <button onclick="document.getElementById('markdown-image-upload').click()">🖼️</button>
            </div>
        </div>
        <input type="file" id="markdown-image-upload" style="display: none;" 
               accept="image/*" onchange="handleImageUpload(this)">
        <textarea id="markdown-input"></textarea>
    </div>
    
    <!-- Right Panel: Preview -->
    <div class="markdown-editor-panel">
        <div class="markdown-editor-header">
            <span>👁️ Live Preview</span>
        </div>
        <div id="markdown-preview"></div>
    </div>
</div>

CSS Layout

The key to a good editor experience is making both panels fill the viewport height:

.markdown-editor-container {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 20px;
    height: calc(100vh - 400px);
    min-height: 600px;
}

.markdown-editor-panel {
    display: flex;
    flex-direction: column;
    height: 100%;
    overflow: hidden;
}

#markdown-input, #markdown-preview {
    flex: 1;
    overflow-y: auto;
    padding: 15px;
}

The calc(100vh - 400px) accounts for the form fields above and below the editor, while min-height: 600px ensures usability on smaller screens.

Implementing Live Preview

The live preview uses marked.js for markdown parsing and highlight.js for code syntax highlighting:

// Configure marked.js
marked.setOptions({
    breaks: true,
    gfm: true, // GitHub Flavored Markdown
    highlight: function(code, lang) {
        if (lang && hljs.getLanguage(lang)) {
            return hljs.highlight(code, { language: lang }).value;
        }
        return code;
    }
});

// Update preview on input
function updatePreview() {
    const markdownText = markdownInput.value;
    const html = marked.parse(markdownText);
    markdownPreview.innerHTML = html;
    
    // Apply syntax highlighting to code blocks
    markdownPreview.querySelectorAll('pre code').forEach((block) => {
        hljs.highlightElement(block);
    });
}

// Listen for changes
markdownInput.addEventListener('input', updatePreview);

This approach provides instant feedback as you type, making it easy to spot formatting issues immediately.

Synchronized Scrolling

One of the most satisfying features is synchronized scrolling - when you scroll the editor, the preview scrolls proportionally:

let isScrollingSynced = true;
let scrollTimeout;

markdownInput.addEventListener('scroll', function() {
    if (!isScrollingSynced) return;
    
    // Calculate scroll position as percentage
    const scrollPercentage = this.scrollTop / 
        (this.scrollHeight - this.clientHeight);
    
    // Apply to preview
    isScrollingSynced = false;
    markdownPreview.scrollTop = scrollPercentage * 
        (markdownPreview.scrollHeight - markdownPreview.clientHeight);
    
    // Re-enable sync after delay to prevent infinite loop
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(() => {
        isScrollingSynced = true;
    }, 100);
});

// Same logic for preview → editor scrolling
markdownPreview.addEventListener('scroll', function() {
    // Mirror implementation...
});

The isScrollingSynced flag prevents infinite loops when one panel's scroll triggers the other's scroll event.

Image Upload System

Frontend: User Interface

The image upload button triggers a hidden file input:

function handleImageUpload(input) {
    const file = input.files[0];
    if (!file) return;
    
    // Show upload status
    statusDiv.textContent = 'Uploading image...';
    statusDiv.style.display = 'block';
    
    // Create form data
    const formData = new FormData();
    formData.append('image', file);
    formData.append('action', 'upload_blog_image');
    
    // Upload via AJAX
    fetch('blog.php', {
        method: 'POST',
        body: formData
    })
    .then(response => response.json())
    .then(data => {
        if (data.success) {
            // Insert markdown at cursor position
            const imageMarkdown = `![${data.filename}](/assets/images/blog/${data.filename})`;
            insertAtCursor(imageMarkdown);
            updatePreview();
            
            statusDiv.textContent = `✓ Image uploaded: ${data.filename}`;
        }
    });
}

Backend: PHP Processing

The PHP backend handles uploads, converts images to WebP for optimization, and returns the filename:

if ($action === 'upload_blog_image') {
    header('Content-Type: application/json');
    
    if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
        echo json_encode(['success' => false, 'error' => 'No file uploaded']);
        exit;
    }
    
    $upload_dir = __DIR__ . '/assets/images/blog/';
    $file_tmp = $_FILES['image']['tmp_name'];
    $file_ext = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION));
    
    // Generate unique filename
    $safe_name = preg_replace('/[^a-z0-9_-]/', '_', 
        strtolower(pathinfo($_FILES['image']['name'], PATHINFO_FILENAME)));
    $new_filename = $safe_name . '_' . uniqid() . '.webp';
    $target_path = $upload_dir . $new_filename;
    
    // Convert to WebP for optimization
    $image = null;
    if ($file_ext === 'jpg' || $file_ext === 'jpeg') {
        $image = imagecreatefromjpeg($file_tmp);
    } elseif ($file_ext === 'png') {
        $image = imagecreatefrompng($file_tmp);
    } elseif ($file_ext === 'gif') {
        $image = imagecreatefromgif($file_tmp);
    } elseif ($file_ext === 'webp') {
        copy($file_tmp, $target_path);
        echo json_encode(['success' => true, 'filename' => $new_filename]);
        exit;
    }
    
    if ($image) {
        imagewebp($image, $target_path, 85); // 85% quality
        imagedestroy($image);
        echo json_encode(['success' => true, 'filename' => $new_filename]);
    }
}

Key Features:

  • Accepts JPG, PNG, GIF, and WebP formats
  • Converts all to WebP for smaller file sizes
  • Sanitizes filenames to prevent security issues
  • Generates unique names to prevent collisions
  • Returns JSON response for easy frontend handling

Database Storage Strategy

I store both the markdown source and rendered HTML:

CREATE TABLE blog_posts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(255),
    slug VARCHAR(255) UNIQUE,
    markdown TEXT,        -- Source for editing
    content TEXT,         -- Rendered HTML for display
    featured_image VARCHAR(255),
    published BOOLEAN DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Why store both?

  • Markdown: Preserves original formatting for future edits
  • HTML: Faster page loads (no server-side rendering)
  • Flexibility: Can re-render all posts if markdown engine updates

When saving a post:

document.querySelector('form').addEventListener('submit', function() {
    const markdownText = markdownInput.value;
    const html = marked.parse(markdownText);
    
    document.getElementById('content').value = html;      // For display
    document.getElementById('markdown').value = markdownText; // For editing
});

Toolbar Quick Actions

The toolbar provides shortcuts for common markdown syntax:

function insertMarkdown(before, after) {
    const start = markdownInput.selectionStart;
    const end = markdownInput.selectionEnd;
    const text = markdownInput.value;
    const selectedText = text.substring(start, end);
    
    // Insert before and after selected text
    markdownInput.value = text.substring(0, start) + 
                         before + selectedText + after + 
                         text.substring(end);
    
    // Reposition cursor
    markdownInput.focus();
    markdownInput.selectionStart = start + before.length;
    markdownInput.selectionEnd = start + before.length + selectedText.length;
    
    updatePreview();
}

This allows users to:

  • Select text and click "B" to make it bold
  • Click "H2" to insert a heading prefix
  • Click code button to wrap selection in backticks

Challenges and Solutions

Challenge 1: Height Synchronization

Problem: Initially, I tried to make both panels the same height by calculating scrollHeight, but this caused performance issues and janky resizing.

Solution: Use CSS flexbox with height: 100% and let the browser handle it. Much smoother and more predictable.

Challenge 2: Image Upload Feedback

Problem: Users had no indication that their image was uploading or if it succeeded.

Solution: Added a status bar with three states:

  • "Uploading image..." (neutral)
  • "✓ Image uploaded: filename.webp" (success, green)
  • "✗ Upload failed: reason" (error, red)

Challenge 3: WebP Browser Support

Problem: Older PHP installations don't have imagecreatefromwebp().

Solution: If the source is already WebP, just copy it directly instead of trying to re-encode:

elseif ($file_ext === 'webp') {
    copy($file_tmp, $target_path);
    $featured_image = $new_filename;
}

Challenge 4: Scroll Sync Infinite Loop

Problem: When one panel scrolled the other, it would trigger a scroll event that scrolled the first panel again, creating an infinite loop.

Solution: Use a flag to temporarily disable sync, with a timeout to re-enable it:

isScrollingSynced = false;
// ... perform scroll ...
setTimeout(() => { isScrollingSynced = true; }, 100);

Performance Considerations

Client-Side Rendering

Using marked.js on the client means zero server load for preview updates. The preview is generated in the user's browser, keeping the server free for actual database operations.

WebP Conversion

Converting all images to WebP reduces bandwidth by 25-40% compared to JPEG and 50-70% compared to PNG, while maintaining excellent quality at 85% compression.

Debouncing Preview Updates

While not implemented in my version, you could add debouncing to the updatePreview() function for very large documents:

let updateTimeout;
markdownInput.addEventListener('input', function() {
    clearTimeout(updateTimeout);
    updateTimeout = setTimeout(updatePreview, 150);
});

Security Considerations

File Upload Validation

  • Check file extensions against whitelist
  • Validate MIME types
  • Limit file sizes (typically 5-10MB)
  • Sanitize filenames to prevent directory traversal

XSS Prevention

marked.js has built-in XSS protection, but I also sanitize the markdown before rendering in production:

// Store both versions
$markdown = $_POST['markdown']; // Raw markdown for editing
$content = $sanitizer->sanitize(marked.parse($markdown)); // Clean HTML

CSRF Protection

Ensure all form submissions include CSRF tokens to prevent cross-site request forgery.

Future Enhancements

Here are features I'm considering adding:

Auto-Save Drafts

  • Save to localStorage every 30 seconds
  • Restore on browser crash or accidental close

Markdown Templates

  • Pre-built templates for common post types
  • Code snippet library
  • Reusable content blocks

Collaborative Editing

  • WebSocket-based real-time collaboration
  • Show cursor positions of other editors
  • Conflict resolution for simultaneous edits

Advanced Image Handling

  • Drag-and-drop upload
  • Paste from clipboard
  • Image resizing and cropping
  • Alt text editor for accessibility

Conclusion

Building a custom markdown editor gave me exactly what I needed: a fast, distraction-free writing environment with instant visual feedback. The combination of marked.js for parsing, highlight.js for code, and a clean PHP backend for image handling creates a robust content management system.

The key takeaways:

  1. Keep it simple: Don't over-engineer. My editor is ~200 lines of JavaScript and works perfectly.
  2. Use proven libraries: marked.js and highlight.js handle the complex parsing and highlighting.
  3. Optimize storage: Storing both markdown and HTML gives flexibility without sacrificing performance.
  4. Focus on UX: Synchronized scrolling and live preview make writing a pleasure.
  5. Think about images: A good image upload system is crucial for rich content.

The entire editor took about 6 hours to build from scratch, including debugging and testing. If you're building a blog or CMS, I highly recommend the markdown approach - your future self will thank you when you need to migrate or update content.

Try It Yourself

Want to implement something similar? Start with these steps:

  1. Include marked.js and highlight.js via CDN
  2. Create a two-panel layout with CSS Grid
  3. Add an event listener to parse markdown on input
  4. Implement the scroll synchronization
  5. Build the PHP upload handler with WebP conversion

The code is straightforward, and you'll have a production-ready editor in an afternoon.

Screenshot

markdown_screenshot_6933daa33df5c.webp

Questions or suggestions? Feel free to reach out - I'd love to hear how you implement your own markdown editor!


This article was written using the very markdown editor it describes. Meta, isn't it?

Need an Android Developer or a full-stack website developer?

I specialize in Kotlin, Jetpack Compose, and Material Design 3. For websites, I use modern web technologies to create responsive and user-friendly experiences. Check out my portfolio or get in touch to discuss your project.