火芯のMD Editor

火芯のMD Editor 是一款纯前端实现的在线Markdown编辑器,无需安装、跨平台访问、支持实时预览与多种格式导出。(项目更新不再更新文章!)

项目地址http://blog.hxdxw.cn/index.php/18.html
技术栈:原生 JavaScript + HTML + CSS,无任何框架依赖

📑 目录


核心功能

  • 📝 实时预览:左侧编辑、右侧预览,毫秒级响应
  • 🔄 同步滚动:编辑区与预览区滚动联动
  • 🎨 语法高亮:100+ 编程语言代码着色
  • 📤 多格式导出:MD / HTML / PNG / PDF
  • 📱 响应式设计:适配桌面端与移动端
  • 🔌 离线可用:所有依赖本地化,CDN 回退保障

HTML 结构

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>火芯のMD editor</title>
    
    <!-- CSS 模块化加载 -->
    <link rel="stylesheet" href="style-base.css">
    <link rel="stylesheet" href="style-layout.css">
    <link rel="stylesheet" href="style-preview.css">
    <link rel="stylesheet" href="style-modal.css">
    <link rel="stylesheet" href="style-print.css">
    <link rel="stylesheet" href="style-mobile.css">
    
    <!-- 核心依赖 CSS -->
    <link rel="stylesheet" href="lib/all.min.css">
    <link rel="stylesheet" href="lib/github.min.css">
</head>
<body>
    <!-- 加载动画 -->
    <div id="loading-overlay">
        <div class="loading-spinner"></div>
        <div class="loading-text">加载中...</div>
    </div>

    <header>
        <nav>
            <div class="left">
                <div class="logo">火芯のMD editor</div>
                <div class="tools">
                    <!-- 工具栏按钮... -->
                </div>
            </div>
            <!-- 导出下拉菜单 -->
            <div class="export-dropdown">
                <button id="export-btn">导出 <i class="fas fa-chevron-down"></i></button>
                <div class="export-menu">
                    <button id="export-md">导出MD</button>
                    <button id="export-html">导出HTML</button>
                    <button id="export-png">导出PNG</button>
                    <button id="export-pdf">导出PDF</button>
                </div>
            </div>
        </nav>
    </header>

    <main>
        <div class="editor-container">
            <div class="section-header">
                <i class="fas fa-pen-fancy"></i>
                <span>编辑</span>
                <div id="word-count">字符: 0 | 词数: 0 | 行数: 0</div>
            </div>
            <textarea id="editor" placeholder="在这里输入Markdown..."></textarea>
        </div>
        <div class="preview-container">
            <div class="section-header">
                <i class="fas fa-eye"></i>
                <span>预览</span>
            </div>
            <div id="preview"></div>
        </div>
    </main>

    <!-- FAQ 折叠面板 -->
    <div class="faq-container">
        <!-- FAQ 内容... -->
    </div>

    <!-- 弹窗组件 -->
    <div id="code-modal" class="modal">...</div>
    <div id="link-modal" class="modal">...</div>
    <div id="image-modal" class="modal">...</div>
    <div id="table-modal" class="modal">...</div>
    <div id="emoji-modal" class="modal">...</div>

    <!-- JS 模块 -->
    <script src="lib-loader.js"></script>
    <script src="lib/html2canvas.min.js"></script>
    <script src="lib/jspdf.umd.min.js"></script>
    <script src="script-editor.js"></script>
    <script src="script-tools.js"></script>
    <script src="script-export.js"></script>
    <script src="script-ui.js"></script>
    <script src="sample-content.js"></script>

    <footer>
        <p>© Copyright 2026.04 <a href="https://blog.hxdxw.cn" target="_blank">火芯の博客</a></p>
    </footer>
</body>
</html>

设计要点

  • CSS 模块化拆分:基础、布局、预览、弹窗、打印、移动端
  • 加载动画覆盖层:依赖加载期间显示加载状态
  • 模态框复用:通过 class="modal" 统一管理
  • 按需加载:html2canvas 和 jsPDF 仅在导出时使用

CSS 架构

采用「原子化 + 模块化」策略,将样式拆分为多个文件:

style-base.css - 基础样式

/* 全局重置 */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 14px;
    line-height: 1.6;
    color: #24292e;
    background-color: #fafbfc;
}

/* 按钮基础样式 */
button {
    border: none;
    background: none;
    cursor: pointer;
    font-family: inherit;
}

button:hover {
    opacity: 0.8;
}

/* 链接样式 */
a {
    color: #0366d6;
    text-decoration: none;
}

a:hover {
    text-decoration: underline;
}

style-layout.css - 布局样式

/* 头部布局 */
header {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: 50px;
    background: #fff;
    border-bottom: 1px solid #e1e4e8;
    z-index: 100;
    display: flex;
    align-items: center;
    padding: 0 20px;
}

header nav {
    display: flex;
    justify-content: space-between;
    width: 100%;
}

header .left {
    display: flex;
    align-items: center;
    gap: 20px;
}

header .logo {
    font-weight: 600;
    font-size: 16px;
    white-space: nowrap;
}

header .tools {
    display: flex;
    gap: 4px;
}

/* 主内容区布局 */
main {
    display: flex;
    margin-top: 50px;
    height: calc(100vh - 50px);
}

.editor-container,
.preview-container {
    flex: 1;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

.editor-container {
    border-right: 2px solid #ddd;
}

.section-header {
    padding: 10px 15px;
    background: #f6f8fa;
    border-bottom: 1px solid #e1e4e8;
    font-weight: 500;
    display: flex;
    align-items: center;
    gap: 8px;
}

#editor {
    flex: 1;
    padding: 15px;
    border: none;
    resize: none;
    font-family: 'Fira Code', Consolas, monospace;
    font-size: 14px;
    line-height: 1.6;
    outline: none;
}

#preview {
    flex: 1;
    padding: 15px 30px;
    overflow-y: auto;
}

style-preview.css - 预览区渲染样式

/* Markdown 渲染样式 */
#preview h1,
#preview h2,
#preview h3,
#preview h4,
#preview h5,
#preview h6 {
    margin-top: 24px;
    margin-bottom: 16px;
    font-weight: 600;
    line-height: 1.25;
}

#preview h1 {
    font-size: 2em;
    border-bottom: 1px solid #eaecef;
    padding-bottom: 0.3em;
}

#preview h2 {
    font-size: 1.5em;
    border-bottom: 1px solid #eaecef;
    padding-bottom: 0.3em;
}

/* 段落与列表 */
#preview p {
    margin-top: 0;
    margin-bottom: 16px;
}

#preview ul,
#preview ol {
    padding-left: 2em;
    margin-bottom: 16px;
}

#preview li {
    margin: 0.25em 0;
}

/* 代码块 */
#preview pre {
    padding: 16px;
    overflow: auto;
    font-size: 85%;
    line-height: 1.45;
    background-color: #f6f8fa;
    border-radius: 6px;
    margin-bottom: 16px;
}

#preview code {
    background-color: rgba(27, 31, 35, 0.06);
    padding: 0.2em 0.4em;
    margin: 0;
    font-size: 85%;
    border-radius: 3px;
    font-family: 'Fira Code', Consolas, monospace;
}

#preview pre code {
    background-color: transparent;
    padding: 0;
}

/* 表格 */
#preview table {
    border-spacing: 0;
    border-collapse: collapse;
    margin-bottom: 16px;
    width: 100%;
    display: block;
    overflow: auto;
}

#preview th,
#preview td {
    padding: 6px 13px;
    border: 1px solid #dfe2e5;
}

#preview th {
    font-weight: 600;
    background-color: #f6f8fa;
}

/* 行号样式 */
pre.line-numbers {
    position: relative;
    padding-left: 3.8em !important;
    counter-reset: linenumber;
}

pre.line-numbers > code {
    position: relative;
    padding-left: 0 !important;
}

pre.line-numbers > code > .code-line {
    display: block;
    position: relative;
}

pre.line-numbers > code > .code-line::before {
    counter-increment: linenumber;
    content: counter(linenumber);
    position: absolute;
    left: -3.5em;
    width: 3em;
    text-align: right;
    padding-right: 0.5em;
    color: #999;
    border-right: 1px solid #ddd;
    margin-right: 0.5em;
    user-select: none;
    font-size: 0.9em;
}

style-modal.css - 弹窗样式

.modal {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 1000;
    justify-content: center;
    align-items: center;
}

.modal.show {
    display: flex;
}

.modal-content {
    background: #fff;
    padding: 20px;
    border-radius: 8px;
    min-width: 400px;
    max-width: 600px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.modal-content h3 {
    margin-bottom: 15px;
}

.modal-content label {
    display: block;
    margin-bottom: 10px;
}

.modal-content input,
.modal-content textarea {
    width: 100%;
    padding: 8px;
    border: 1px solid #ddd;
    border-radius: 4px;
    margin-top: 4px;
}

.modal-buttons {
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 20px;
}

.modal-buttons button {
    padding: 8px 16px;
    border-radius: 4px;
    background: #0366d6;
    color: #fff;
}

.modal-buttons button.cancel {
    background: #ccc;
}

style-print.css - 打印样式

@media print {
    header,
    .editor-container,
    .faq-container,
    .modal,
    #loading-overlay {
        display: none !important;
    }

    main {
        margin-top: 0;
        height: auto;
    }

    #preview {
        padding: 0;
        overflow: visible;
    }
}

style-mobile.css - 响应式适配

@media (max-width: 768px) {
    main {
        flex-direction: column;
    }

    .editor-container,
    .preview-container {
        height: 50%;
    }

    .editor-container {
        border-right: none;
        border-bottom: 2px solid #ddd;
    }

    header .tools {
        flex-wrap: wrap;
        overflow-x: auto;
    }

    header .tools button {
        padding: 6px 10px;
        font-size: 12px;
    }
}

JavaScript 模块

1. 模块化加载器:lib-loader.js

采用「本地优先 + CDN 回退」策略:

const LibLoader = {
    isReady: false,
    readyCallbacks: [],

    loadWithFallback(type, localPath, cdnUrl, attrs = {}) {
        return new Promise((resolve) => {
            const element = type === 'link' 
                ? document.createElement('link')
                : document.createElement('script');
            
            if (type === 'link') {
                element.rel = 'stylesheet';
                element.href = localPath;
            } else {
                element.src = localPath;
            }
            
            Object.assign(element, attrs);
            
            element.onload = () => resolve({ source: 'local', path: localPath });
            
            element.onerror = () => {
                if (type === 'link') {
                    element.href = cdnUrl;
                } else {
                    element.src = cdnUrl;
                }
                
                element.onerror = null;
                element.onload = () => resolve({ source: 'cdn', path: cdnUrl });
            };
            
            document.head.appendChild(element);
        });
    },

    async load() {
        const basePath = 'lib/';
        
        await Promise.all([
            this.loadWithFallback('link', basePath + 'all.min.css', CDN_FALLBACKS.fontAwesome),
            this.loadWithFallback('link', basePath + 'github.min.css', CDN_FALLBACKS.highlightCss),
            this.loadWithFallback('script', basePath + 'marked.min.js', CDN_FALLBACKS.marked),
            this.loadWithFallback('script', basePath + 'highlight.min.js', CDN_FALLBACKS.highlight)
        ]);
        
        this.isReady = true;
        this.readyCallbacks.forEach(cb => cb());
        this.readyCallbacks = [];
    },

    whenReady(callback) {
        if (this.isReady) {
            callback();
        } else {
            this.readyCallbacks.push(callback);
        }
    }
};

LibLoader.load();

设计亮点

  • Promise.all 并行加载,提升首屏速度
  • 回调队列机制,解决脚本执行顺序问题
  • 静默降级,用户无感知

2. 全局状态管理:window 对象

使用 window 作为模块间通信的「中央总线」:

// script-editor.js - 状态定义
LibLoader.whenReady(function() {
    
    window.editor = document.getElementById('editor');
    window.preview = document.getElementById('preview');
    window.isSyncing = false;
    window.isFullscreen = false;
});

// script-tools.js - 状态消费
LibLoader.whenReady(function() {
    
    const editor = window.editor;
    const preview = window.preview;

    window.isSyncing = true;
    updatePreview();
    window.isSyncing = false;
});

关键机制:使用 isSyncing 标志位防止编辑区↔预览区的滚动同步形成死循环。


3. Markdown 渲染:updatePreview 函数

function updatePreview() {
    // 核心:marked.js 解析 Markdown
    preview.innerHTML = marked.parse(editor.value);
    
    // 代码高亮处理
    preview.querySelectorAll('pre code').forEach((block) => {
        const code = block.textContent;
        const language = block.className
            .replace('language-', '')
            .split(' ')[0] || '';
        
        // 过滤末尾空行
        let lines = code.split('\n');
        if (lines[lines.length - 1] === '') {
            lines = lines.slice(0, -1);
        }
        
        block.parentElement.classList.add('line-numbers');
        
        // 逐行高亮
        block.innerHTML = lines.map((line) => {
            if (line.trim() === '') return '<span class="code-line"> </span>';
            const highlighted = hljs.highlight(line, { 
                language: language || 'plaintext' 
            });
            return `<span class="code-line">${highlighted.value}</span>`;
        }).join('');
    });
}

性能优化:逐行高亮而非一次性处理,避免长代码块阻塞主线程。


4. PNG 导出

document.getElementById('export-png').addEventListener('click', () => {
    const previewClone = preview.cloneNode(true);
    const tempDiv = document.createElement('div');
    tempDiv.style.cssText = 'position:fixed;left:-9999px;top:0;width:100%;padding:20px;background:#fff;';
    tempDiv.innerHTML = previewClone.innerHTML;
    document.body.appendChild(tempDiv);
    
    html2canvas(tempDiv, {
        allowTaint: true,
        useCORS: true,
        backgroundColor: '#ffffff',
        scale: 2,
        logging: false
    }).then(canvas => {
        const link = document.createElement('a');
        link.download = getFilename('png');
        link.href = canvas.toDataURL('image/png');
        link.click();
        document.body.removeChild(tempDiv);
    }).catch(err => {
        console.error('导出PNG失败:', err.message);
        document.body.removeChild(tempDiv);
    });
});

5. PDF 导出:分页算法

document.getElementById('export-pdf').addEventListener('click', () => {
    const { jsPDF } = window.jspdf;
    
    const pdfWidth = 210;
    const pdfHeight = 297;
    const margin = 15;
    const contentWidthMM = pdfWidth - (margin * 2);
    
    const tempContainer = document.createElement('div');
    tempContainer.style.cssText = 'position:fixed;left:-9999px;top:0;width:794px;background:#fff;padding:40px;box-sizing:border-box;';
    tempContainer.innerHTML = preview.innerHTML;
    document.body.appendChild(tempContainer);
    
    // 等待所有图片加载完成
    const images = tempContainer.querySelectorAll('img');
    Promise.all(Array.from(images).map(img => {
        return new Promise(resolve => {
            if (img.complete) resolve();
            else {
                img.onload = resolve;
                img.onerror = resolve;
            }
        });
    })).then(() => {
        return html2canvas(tempContainer, {
            allowTaint: true,
            useCORS: true,
            backgroundColor: '#ffffff',
            scale: 2,
            width: 874
        });
    }).then(canvas => {
        const pdf = new jsPDF('p', 'mm', 'a4');
        
        const scale = canvas.width / contentWidthMM;
        const pageHeightPx = (pdfHeight - margin * 2) * scale;
        
        let sourceY = 0;
        let pageIndex = 0;
        
        while (sourceY < canvas.height) {
            const remainingHeight = canvas.height - sourceY;
            const sliceHeight = Math.min(pageHeightPx, remainingHeight);
            
            const sliceCanvas = document.createElement('canvas');
            sliceCanvas.width = canvas.width;
            sliceCanvas.height = sliceHeight;
            const ctx = sliceCanvas.getContext('2d');
            ctx.fillStyle = '#ffffff';
            ctx.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height);
            ctx.drawImage(canvas, 0, sourceY, canvas.width, sliceHeight, 
                          0, 0, canvas.width, sliceHeight);
            
            const imgHeightInMM = sliceHeight / scale;
            
            if (pageIndex > 0) pdf.addPage();
            pdf.addImage(sliceCanvas.toDataURL('image/png'), 'PNG', 
                         margin, margin, contentWidthMM, imgHeightInMM);
            
            sourceY += pageHeightPx;
            pageIndex++;
        }
        
        document.body.removeChild(tempContainer);
        pdf.save(getFilename('pdf'));
    }).catch(err => {
        console.error('导出PDF失败:', err.message);
    });
});

算法要点

  1. 固定 canvas.width = 874px
  2. 计算 scale = canvas.width / contentWidthMM 作为转换桥梁
  3. pageHeightPx 分页裁剪 canvas
  4. 预加载所有图片,避免导出时图片缺失

6. 工具栏插入:insertText 函数

function insertText(before, after = '', newline = false) {
    if (editor.value.startsWith('# Markdown 语法教程')) {
        editor.value = '';
        updatePreview();
        updateWordCount();
    }
    
    const start = editor.selectionStart;
    const end = editor.selectionEnd;
    const selected = editor.value.substring(start, end);
    
    let replacement = before + selected + after;
    if (newline) replacement += '\n';
    editor.value = editor.value.substring(0, start) 
                  + replacement 
                  + editor.value.substring(end);
    
    const newPos = start + before.length + selected.length 
                 + (newline ? after.length + 1 : after.length);
    
    window.isSyncing = true;
    updatePreview();
    updateWordCount();
    window.isSyncing = false;
    
    editor.focus();
    editor.setSelectionRange(newPos, newPos);
}

function insertHeading(level) {
    insertText('#'.repeat(level) + ' ', '', true);
}

document.getElementById('bold').addEventListener('click', () => insertText('**', '**'));
document.getElementById('italic').addEventListener('click', () => insertText('*', '*'));
document.getElementById('h1').addEventListener('click', () => insertHeading(1));

文件结构

markdown-editor/
├── index.html           # 主页面
├── lib-loader.js        # 依赖加载器
├── script-editor.js     # 核心功能
├── script-tools.js      # 工具栏
├── script-export.js     # 导出功能
├── script-ui.js         # UI 交互
├── sample-content.js    # 示例内容
├── lib/                 # 本地依赖
│   ├── marked.min.js
│   ├── highlight.min.js
│   ├── html2canvas.min.js
│   └── jspdf.umd.min.js
└── style-*.css          # 样式模块
    ├── style-base.css   # 基础重置
    ├── style-layout.css # 布局结构
    ├── style-preview.css # 预览渲染
    ├── style-modal.css  # 弹窗组件
    ├── style-print.css  # 打印适配
    └── style-mobile.css # 响应式

希望这篇解析对你理解前端项目开发有所帮助! 🚀

评论区:

还没有评论,快来抢沙发吧!