前端开发您现在的位置是:首页 > 博客日志 > 前端开发

UEditor集成Markdown编辑功能完整方案,支持Markdown与HTML的双向转换

<a href='mailto:'>微wx笑</a>的头像微wx笑 2025-03-02前端开发 1 0关键字: Ueditor  Markdown  

上一篇 UEditor集成Markdown编辑功能方案,实现的思路是支持Markdown格式的内容插入功能,后来一想,既然可以将Markdown格式转换为HTML格式,为什么不同时支持HTML格式转Markdown格

上一篇 UEditor集成Markdown编辑功能方案,实现的思路是支持Markdown格式的内容插入功能,后来一想,既然可以将Markdown格式转换为HTML格式,为什么不同时支持HTML格式转Markdown格式呢!GAr无知

同时对界面也做了样式上的美化;GAr无知

image.pngGAr无知

点击 Ueditor 工具栏上的 markdown 图标,如果 编辑器内有内容,就直接转换为 Markdown 格式,显示在Markdown 格式编辑器的编辑框内,这样就实现的双向的编辑与转换。GAr无知

效果如下:GAr无知

image.pngGAr无知

依赖库

markdown-it 是一个功能强大、快速且轻量级的 JavaScript 库,用于将 Markdown 文本解析并渲染为 HTML。以下为你详细介绍 markdown-it 的相关内容:GAr无知

特点

  1. 速度快:采用了高效的解析算法,能够快速处理大量的 Markdown 文本。GAr无知

  2. 扩展性强:支持通过插件扩展其功能,例如添加代码高亮、表格渲染、任务列表等功能。GAr无知

  3. 符合规范:遵循 CommonMark 规范,确保 Markdown 解析的一致性。GAr无知

  4. 轻量级:体积小巧,不会给项目带来过多的负担。GAr无知

turndown 是一个用于将 HTML 转换为 Markdown 的 JavaScript 库,它在处理 HTML 内容并将其转换为更易读、可编辑的 Markdown 格式方面表现出色。下面为你详细介绍 turndown 的相关信息:

特点


GAr无知

  1. 简单易用:提供了简洁的 API,能轻松地将 HTML 字符串转换为 Markdown 文本。GAr无知

  2. 高度可定制:允许用户自定义规则,以满足不同的转换需求。GAr无知

  3. 广泛兼容性:可以在浏览器环境和 Node.js 环境中使用。GAr无知


GAr无知

UI代码

<div id="mdOverlay" style="display:none;position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(0,0,0,0.5); opacity: 0.3; filter: alpha(opacity=30); z-index: 9999;"></div>
<div id="mdModal" style="display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: white; box-shadow: 1px 1px 30px rgba(0,0,0,0.3); z-index: 10000; border-radius: 3px;">
  <div style="padding:10px; line-height: 22px; border-bottom: 1px solid #ddd; font-size: 14px; color: #333; overflow: hidden; background-color: #f8f8f8; border-radius: 3px 3px 0 0;">Markdown格式编辑器
  <span class="mdDlgCloseBtn" onclick="closeMDDialog()" style="float:right; width: 16px;
    height: 16px;
    justify-content: center;
    align-items: center;
    color: rgb(255, 255, 255);
    background-color: rgb(0, 0, 0);
    opacity: 0.7;
    cursor: pointer;
    line-height: 16px;
    text-align: center;
    border-radius: 50%;
    transition: all 0.3s ease 0s;"><svg width="8" height="10" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg" hanging="8"><path d="M7.83725 1.30615L1.30664 7.83676M1.30664 1.30615L7.83725 7.83676" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>
  </div>
  <textarea id="mdContent" style="width:600px;height:300px; margin: 10px 10px 0 10px;"></textarea>
  <div style="text-align: right; padding: 10px;">
    <button type="button" onclick="updateEditor()">确定</button>
    <button type="button" onclick="closeMDDialog()">关闭</button>
  </div>
</div>

脚本依赖

<script type="text/javascript" charset="utf-8" src="third-party/turndown720.js"></script>
<script type="text/javascript" charset="utf-8" src="third-party/markdown-it1301.min.js"></script>

完整脚本实现

//以下为Markdown格式内容器相关代码
// 初始化转换器
const mdParser = window.markdownit();
const turndownService = new TurndownService({
  codeBlockStyle: 'fenced', // 强制使用围栏代码块
  headingStyle: 'atx'       // 保证标题转换兼容性
});

// ================= 多行代码块规则(高优先级) =================
turndownService.addRule('codeBlocks', {
  filter: function(node) {
    // 匹配两种情况:1. PRE>CODE  2. 单独的PRE(兼容异常情况)
    return node.nodeName === 'PRE' && (
      node.firstChild?.nodeName === 'CODE' || 
      !node.querySelector('code')
    );
  },
  replacement: function(content, node) {
    // 提取代码内容
    const codeNode = node.querySelector('code') || node;
    let codeText = codeNode.textContent;

    // 优化1:清理首尾空白
    codeText = codeText.replace(/^\n+|\n+$/g, '');

    // 优化2:转义内部反引号
    codeText = codeText.replace(/`/g, '\\`');

    // 优化3:识别语言类型(支持language-xxx和lang-xxx格式)
    const langMatch = codeNode.className.match(/(?:lang|language)-(\w+)/i);
    const lang = langMatch ? langMatch[1] : '';

    // 优化4:处理混合内容的情况
    if (node !== codeNode.parentNode) {
      return `\`\`\`${lang}\n${codeText}\n\`\`\`\n\n`;
    }

    return `\`\`\`${lang}\n${codeText}\n\`\`\`\n\n`;
  }
});

// ================= 行内代码/意外多行代码(低优先级) =================
turndownService.addRule('inlineCode', {
  filter: ['code'],
  replacement: function(content, node) {
    // 优化5:检测是否包含换行符
    const hasNewline = /\n/.test(content);

    // 优化6:处理嵌套在pre外的代码
    if (hasNewline || node.parentNode.nodeName !== 'PRE') {
      const escaped = content.replace(/`/g, '\\`');
      return hasNewline 
        ? `\`\`\`\n${escaped}\n\`\`\`` 
        : `\`${escaped}\``;
    }
    
    return content; // 已在codeBlocks规则处理
  }
});

turndownService.addRule('table', {   // 添加名为'table'的新转换规则
  filter: 'table',                // 指定过滤目标为<table>标签
  replacement: (content) => `\n${content}\n` 
  // 定义转换方式:在表格内容前后添加换行符
});

let currentEditor = null;

function showMDDialog(editor) {
  currentEditor = editor;
  // 获取编辑器原始内容并转换
  const htmlContent = editor.getContent();
  const markdownContent = turndownService.turndown(htmlContent);
  
  document.getElementById('mdContent').value = markdownContent;
  document.getElementById('mdModal').style.display = 'block';
  document.getElementById('mdOverlay').style.display = 'block';
}

function closeMDDialog() {
  document.getElementById('mdModal').style.display = 'none';
  document.getElementById('mdOverlay').style.display = 'none';
}

// 更新编辑器内容
function updateEditor() {
  const markdownContent = document.getElementById('mdContent').value;
  const newHtml = mdParser.render(markdownContent);
  
  // 使用setContent完全替换编辑器内容
  currentEditor.setContent(newHtml);
  closeMDDialog();
}

UE.registerUI('insert_markdown', function(editor, uiName) {
    editor.registerCommand(uiName, {
        execCommand: function() {
          showMDDialog(editor);
        }
    });
    var btn = new UE.ui.Button({
        name: uiName,
        title: "Markdown格式编辑器",
        cssRules: 'background-position: -775px -76px;',
        onclick: function() {
            editor.execCommand(uiName);
        }
    });
    editor.addListener('selectionchange', function() {
        var state = editor.queryCommandState(uiName);
        if (state == -1) {
            btn.setDisabled(true);
            btn.setChecked(false);
        } else {
            btn.setDisabled(false);
            btn.setChecked(state);
        }
    });
    return btn;});


GAr无知

2025-04-04 更新GAr无知

后来好像又做过修改优化,结果导致将HTML转为Markdown的时候很多样式都丢失了,然后又修改的方案如下:GAr无知

//以下为Markdown格式内容器相关代码
// 初始化转换器
const mdParser = window.markdownit({
  breaks: true,
  // 强制保留换行结构
  typographer: false,
  // 禁用段落合并
  linkify: false,
  renderer: {
    rules: {
      softbreak: null,
      hardbreak: null,
      inline: null
    }
  }
});

// 自定义渲染规则,处理段落
mdParser.renderer.rules.paragraph_open = function (tokens, idx, options, env, self) {
    //console.log(tokens);
    const prevToken = idx > 0 ? tokens[idx - 1] : null;
    // 如果前一个 token 是段落结束且中间有空白行,添加额外的换行符
    if (prevToken && (prevToken.type === 'paragraph_close')) {
        return '<p></p>' + self.renderToken(tokens, idx, options, env, self);
    }
    return self.renderToken(tokens, idx, options, env, self);
};

//softbreak: 软换行
mdParser.renderer.rules.inline = function (tokens, idx, options, env, self) {
    const prevToken = idx > 0 ? tokens[idx - 1] : null;
    console.log(tokens[idx]);
    // 如果前一个 token 是段落结束且中间有空白行,添加额外的换行符
    if (prevToken && (prevToken.type === 'paragraph_close')) {
        return '<p></p>' + self.renderToken(tokens, idx, options, env, self);
    }
    return self.renderToken(tokens, idx, options, env, self);
};
//hardbreak: 硬换行。
mdParser.renderer.rules.hardbreak = function (tokens, idx, options, env, self) {
    const prevToken = idx > 0 ? tokens[idx - 1] : null;
  console.log(tokens[idx]);
    // 如果前一个 token 是段落结束且中间有空白行,添加额外的换行符
    if (prevToken && (prevToken.type === 'paragraph_close')) {
        return '<p></p>' + self.renderToken(tokens, idx, options, env, self);
    }
    return self.renderToken(tokens, idx, options, env, self);
};

const turndownService = new TurndownService({
  codeBlockStyle: 'fenced',
  headingStyle: 'atx',
  br: '\n', // 强制换行符更明显
  blankReplacement: (content, node) => node.isBlock ? '\n\n' : ''
});

//====================================
turndownService.addRule('ueditorCodeBlock', {
  filter: function(node) {
    // 匹配包含brush语法的pre标签
    return node.nodeName === 'PRE' && 
           /\bbrush:/.test(node.className)
  },
  replacement: function(content, node) {
    // 提取语言类型
    const brushMatch = node.className.match(/brush:([^;]+)/)
    const lang = brushMatch ? brushMatch[1].split(';')[0] : ''

    // 处理多层嵌套结构(如pre>code或直接文本)
    const codeContent = node.querySelector('code') 
      ? node.querySelector('code').textContent 
      : node.textContent

    // 清理内容并转义反引号
    const escapedContent = codeContent
      .trim()
      .replace(/`/g, '\\`')

    return `\`\`\`${lang}\n${escapedContent}\n\`\`\`\n\n`
  }
})


turndownService.addRule('enhancedCodeBlock', {
  filter: ['pre'],
  replacement: function(content, node) {
    const codeNode = node.querySelector('code') || node;
    let lang = codeNode.getAttribute('data-lang') || 
              codeNode.className.match(/(?:lang|language)-(\w+)/i)?.[1] || '';
    return `\`\`\`${lang}\n${codeNode.textContent.trim()}\n\`\`\`\n\n`;
  }
});

    // ================= 修复后的代码块规则 =================
turndownService.addRule('codeBlocks', {
  filter: function(node) {
    // 修正4:更全面的代码块检测
    if (node.nodeName === 'PRE') return true;
    if (node.nodeName === 'CODE' && node.parentNode.nodeName !== 'PRE') return true;
    return false
  },
  replacement: function(content, node) {
    // 修正5:统一处理pre和code的情况
    const isCodeBlock = node.nodeName === 'PRE';
    const codeNode = isCodeBlock ? (node.querySelector('code') || node) : node;
    
    // 清理首尾空白
    let codeText = codeNode.textContent
      .replace(/^\s+/, '')
      .replace(/\s+$/, '');
    
    // 修正6:智能转义反引号(仅当需要时)
    const backtickCount = (codeText.match(/`+/g) || []).reduce((max, match) => 
      Math.max(max, match.length), 0);
    const fence = '`'.repeat(Math.max(3, backtickCount + 1));
    
    // 获取语言类型
    const langMatch = codeNode.className.match(/(?:lang|language)-(\S+)/i);
    const lang = langMatch ? langMatch[1] : '';
    
    return `\n\n${fence}${lang}\n${codeText}\n${fence}\n\n`;
  }
});
// ================= 增强表格转换规则 =================
turndownService.addRule('table', {
  filter: 'table',
  replacement: function(content, node) {
    const rows = Array.from(node.querySelectorAll('tr'));
    if (rows.length === 0) return '';
    
    // 处理表头
    const headers = Array.from(rows.shift().querySelectorAll('th, td'))
      .map(cell => cell.textContent.trim());
    
    // 生成分隔线
    const separator = '| ' + headers.map(() => '---').join(' | ') + ' |';
    
    // 处理内容行
    const body = rows.map(row => 
      '| ' + Array.from(row.querySelectorAll('td'))
        .map(cell => cell.textContent.trim().replace(/\|/g, '\\|'))
        .join(' | ') + ' |'
    ).join('\n');
    
    return `\n\n| ${headers.join(' | ')} |\n${separator}\n${body}\n\n`;
  }
});


//====================================

let currentEditor = null;

function showMDDialog(editor) {
  currentEditor = editor;
  // 获取编辑器原始内容并转换
  const htmlContent = editor.getContent();
  const markdownContent = turndownService.turndown(htmlContent);
  
  document.getElementById('mdContent').value = markdownContent;
  document.getElementById('mdModal').style.display = 'block';
  document.getElementById('mdOverlay').style.display = 'block';
}

function closeMDDialog() {
  document.getElementById('mdModal').style.display = 'none';
  document.getElementById('mdOverlay').style.display = 'none';
}

// 更新编辑器内容
function updateEditor() {
  const markdownContent = document.getElementById('mdContent').value;
  const newHtml = mdParser.render(markdownContent) + "<p></p>";
  
  // 使用setContent完全替换编辑器内容
  currentEditor.setContent(newHtml);
  closeMDDialog();
}

UE.registerUI('insert_markdown', function(editor, uiName) {
    editor.registerCommand(uiName, {
        execCommand: function() {
          showMDDialog(editor);
        }
    });
    var btn = new UE.ui.Button({
        name: uiName,
        title: "插入Markdown格式内容",
        cssRules: 'background-position: -775px -76px;',
        onclick: function() {
            editor.execCommand(uiName);
        }
    });
    editor.addListener('selectionchange', function() {
        var state = editor.queryCommandState(uiName);
        if (state == -1) {
            btn.setDisabled(true);
            btn.setChecked(false);
        } else {
            btn.setDisabled(false);
            btn.setChecked(state);
        }
    });
    return btn;});


GAr无知

本文由 微wx笑 创作,采用 署名-非商业性使用-相同方式共享 4.0 许可协议,转载请附上原文出处链接及本声明。
原文链接:https://www.ivu4e.cn/blog/front/2025-03-02/2040.html

很赞哦! () 有话说 ()