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

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

<a href='mailto:'>微wx笑</a>的头像微wx笑 2025-03-02前端开发35 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代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<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>

脚本依赖

1
2
<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>

完整脚本实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
//以下为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无知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//以下为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

很赞哦! (13) 有话说 (0)

文章评论