最终效果

页面
src/renderer/src/App.vue
<!-- 效果预览 -->
<div class="previewBox">
<div class="panelTitle">最终效果</div>
<div
ref="previewRef"
class="preview"
@click="handle_preview_click"
@scroll="update_currentOutlineId"
v-html="parsedMarkdown"
></div>
</div>相关样式
.panelTitle {
font-size: 16px;
font-weight: bold;
text-align: center;
background-color: #f0f0f0;
height: 34px;
line-height: 34px;
}
.previewBox {
flex: 1;
min-width: 0;
border: 1px solid gray;
border-left: none;
display: flex;
flex-direction: column;
}
.preview {
flex: 1;
overflow: auto;
padding: 10px;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
}核心变量
const parsedMarkdown = computed(() => {
return MarkdownParser(markdownContent.value).content || ''
})自研 Markdown 语法解析器
src/renderer/src/markdownParser.ts
基本原理
- 单行的 Markdown 语法在遍历文本的每一行时进行解析转换
- 多行的 Markdown 语法对文本全文进行匹配替换解析转换
export const MarkdownParser = function (mdString: string): {
content: string
outline: outlineItemType[]
} {
const lineList = mdString.split('\n')
const outlineList: outlineItemType[] = []
let startUnderline = false
let startOrderedList = false
let startQuote = false
const newlineList = lineList.map((line, index) => {
// 前缀标签
let preTag: string = ''
// 加粗
line = parseBoldMarkdown(line)
// 斜体
line = parseMarkdownItalics(line)
// 删除线
line = parseStrikethrough(line)
// 行内代码
line = parseInlineCode(line)
// 标题
if (line.startsWith('#')) {
const resultTemp = parseMarkdownHeadings(line, index)
if (resultTemp) {
line = resultTemp.content
outlineList.push({
...resultTemp.outline,
index
})
}
}
// 图片
if (line.startsWith('![')) {
line = mdImageToHtml(line)
}
// 链接
line = convertMdLinksToHtml(line)
// 待办
if (line.startsWith('- [')) {
line = convertTodoMarkdownToHtml(line, index)
}
// 引用
if (line.startsWith('> ')) {
if (startQuote) {
line = parseQuote(line)
} else {
line = '<blockquote>' + parseQuote(line)
}
startQuote = true
} else {
if (startQuote) {
preTag = '</blockquote>'
}
startQuote = false
}
// 无序列表
if (line.startsWith('- ')) {
if (startUnderline) {
// 若之前是无序列表,直接添加列表项
line = parseUnorderedList(line)
} else {
// 若之前不是无序列表,则开头添加'<ul>'
line = '<ul>' + parseUnorderedList(line)
}
startUnderline = true
} else {
if (startUnderline) {
preTag = '</ul>'
}
startUnderline = false
}
// 有序列表
if (/^\d+\.\s+/.test(line)) {
if (startOrderedList) {
// 若之前是有序列表,直接添加列表项
line = parseOrderedList(line)
} else {
// 若之前不是有序列表,则开头添加'<ol>'
line = '<ol>' + parseOrderedList(line)
}
startOrderedList = true
} else {
// 若之前是有序列表,则结尾添加'</ol>'
if (startOrderedList) {
preTag = '</ol>'
}
startOrderedList = false
}
return preTag + line
})
let result = newlineList.join('\n')
// 解析代码块
result = parseCodeBlocks(result)
// 解析表格
result = parseTable(result)
return {
content: result,
outline: outlineList
}
}解析 - - 标题
/**
* 将 Markdown 标题(# 开头)转换为 HTML 标题标签
* @param markdown - 输入的 Markdown 文本
* @returns 转换后的 HTML 文本
*/
function parseMarkdownHeadings(
markdown: string,
index: number
): {
content: string
outline: outlineItemType
} | void {
// 正则表达式匹配 Markdown 标题
const headingRegex = /^(#+)\s+(.*)$/gm
const match = headingRegex.exec(markdown)
if (match) {
const level = match[1].length // 标题等级由#的数量决定
const text = match[2].trim()
const id = index.toString() // 生成锚点ID
return {
content: `<h${level} id=${id} >${escapeHTML(text)}</h${level}>`,
outline: {
text,
id,
level
}
}
}
}
// 解析 Markdown 加粗语法为 HTML
function parseBoldMarkdown(markdown: string): string {
// 正则表达式匹配 **粗体** 和 __粗体__
const boldRegex = /(\*\*|__)(.*?)\1/g
return markdown.replace(boldRegex, (_match, _delimiter, content) => {
// 将匹配到的内容转换为 HTML 标签
return `<strong>${escapeHTML(content)}</strong>`
})
}解析 - - 加粗
// 解析 Markdown 加粗语法为 HTML
function parseBoldMarkdown(markdown: string): string {
// 正则表达式匹配 **粗体** 和 __粗体__
const boldRegex = /(\*\*|__)(.*?)\1/g
return markdown.replace(boldRegex, (_match, _delimiter, content) => {
// 将匹配到的内容转换为 HTML 标签
return `<strong>${escapeHTML(content)}</strong>`
})
}解析 - - 斜体
/**
* 将 Markdown 斜体语法 (*text* 或 _text_) 解析为 HTML
* @param markdown Markdown 文本
* @returns 转换后的 HTML 文本
*/
function parseMarkdownItalics(markdown: string): string {
// 处理 *text* 格式的斜体 (改进版本,处理嵌套星号)
const asteriskRegex = /(?<!\*)\*(?!\*)([\s\S]*?)(?<!\*)\*(?!\*)/g
const withAsterisks = markdown.replace(asteriskRegex, '<em>$1</em>')
// 处理 _text_ 格式的斜体 (需要避免与下划线单词冲突)
const underscoreRegex = /(?<!_)(?<!\w)_(?!_)([\s\S]*?)(?<!_)(?!\w)_(?!_)/g
return withAsterisks.replace(underscoreRegex, '<em>$1</em>')
}解析 - - 删除线
// 解析 Markdown 删除线语法为 HTML
function parseStrikethrough(mdText: string): string {
// 正则表达式用于匹配 Markdown 删除线语法 (~~text~~)
const strikethroughRegex = /~~([^~]+)~~/g
// 使用 replace 方法将匹配到的删除线语法转换为 HTML 的 <s> 标签
return mdText.replace(strikethroughRegex, '<s>$1</s>')
}解析 - - 无序列表
//解析 Markdown 无序列表语法为 HTML
function parseUnorderedList(mdText: string): string {
return mdText.replace(/^([-+*])\s+(.*)$/g, '<li>$2</li>')
}解析 - - 有序列表
//解析 Markdown 有序列表语法为 HTML
function parseOrderedList(mdText: string): string {
// 匹配以数字加 . 开头的有序列表项
return mdText.replace(/^(\d+)\.\s+(.*)$/g, '<li>$2</li>')
}解析 - - 待办列表
// 将 Markdown 待办列表解析为 HTML
function convertTodoMarkdownToHtml(mdText: string, index: number): string {
// 定义待办列表的正则表达式
const todoRegex = /^(\s*)- \[([ xX])\] (.*)$/gm
// 使用 replace 方法进行转换
return mdText.replace(todoRegex, (_match, indent, status, content) => {
const isChecked = status.trim().toLowerCase() === 'x'
const checkbox = isChecked ? `<input type="checkbox" checked >` : '<input type="checkbox" >'
return `${indent}<label data-line_index=${index} class="todo-item">${checkbox} ${content}</label>`
})
}解析 - - 引用
//解析 Markdown 引用语法为 HTML
function parseQuote(mdText: string): string {
// 正则表达式匹配引用行
const quoteRegex = /^>\s*(.*)$/gm
// 移除每行开头的 > 符号并转义 HTML 特殊字符
const content = mdText.replace(quoteRegex, (_match, content) => {
return escapeHTML(content)
})
return `<p>${content}</p>`
}解析 - - 超链接
/**
* 将 Markdown 格式的超链接转换为 HTML 格式
* @param mdText 包含 Markdown 超链接的文本
* @returns 转换后的 HTML 文本
*/
function convertMdLinksToHtml(mdText: string): string {
// 匹配 Markdown 超链接的正则表达式
const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
// 替换所有匹配到的 Markdown 超链接为 HTML 超链接
return mdText.replace(mdLinkRegex, (_, text, url) => {
// 构建 HTML 超链接
return `<a href="${url}">${text}</a>`
})
}解析 - - 行内代码
// 解析行内代码
function parseInlineCode(mdText: string): string {
const inlineCodeRegex = /`([^`]+)`/g
return mdText.replace(inlineCodeRegex, '<code>$1</code>')
}解析 - - 代码块
// 解析代码块
function parseCodeBlocks(mdText: string): string {
// 处理标准代码块 (```)
const fencedCodeBlockRegex = /```(?:(\w+)\n)?([\s\S]*?)```/g
mdText = mdText.replace(fencedCodeBlockRegex, (_match, language, content) => {
const codeContent = escapeHTML(content.trim())
if (language) {
return `<pre><code class="language-${language}">${codeContent}</code></pre>`
}
return `<pre><code>${codeContent}</code></pre>`
})
return mdText
}解析 - - 表格
// 解析 Markdown 表格为 HTML 表格
function parseMarkdownTable(mdTable: string): string {
// 按行分割并过滤空行
const lines = mdTable
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('|') && line.endsWith('|'))
if (lines.length < 2) return ''
// 解析表头
const headerLine = lines[0]
const headers = headerLine
.split('|')
.slice(1, -1)
.map((cell) => cell.trim())
// 解析分隔线,确定对齐方式
const separatorLine = lines[1]
const alignments = separatorLine
.split('|')
.slice(1, -1)
.map((part) => {
part = part.trim()
if (part.startsWith(':') && part.endsWith(':')) return 'center'
if (part.endsWith(':')) return 'right'
return 'left'
})
// 解析内容行
const rows = lines.slice(2).map((line) => {
return line
.split('|')
.slice(1, -1)
.map((cell) => cell.trim())
})
// 生成 HTML
let html = '<table class="markdown-table">'
// 表头
html += '<thead><tr>'
headers.forEach((header, index) => {
const align = alignments[index] || 'left'
html += `<th class="align-${align}">${header}</th>`
})
html += '</tr></thead>'
// 表体
html += '<tbody>'
rows.forEach((row) => {
html += '<tr>'
row.forEach((cell, index) => {
const align = alignments[index] || 'left'
html += `<td align="${align}">${cell}</td>`
})
html += '</tr>'
})
html += '</tbody></table>'
return html
}解析 - - 图片
/**
* 将 Markdown 格式的图片转换为 HTML 格式
* @param md 包含图片的 Markdown 文本
* @returns 转换后的 HTML 文本
*/
function mdImageToHtml(md: string): string {
// 匹配普通图片语法: 
const imageRegex = /!\[(.*?)\]\((.*?)(?:\s+"(.*?)")?\)/g
// 匹配嵌套在链接中的图片语法: [](链接地址)
const linkedImageRegex = /\[!\[(.*?)\]\((.*?)(?:\s+"(.*?)")?\)\]\((.*?)\)/g
// 先处理嵌套在链接中的图片
let result = md.replace(linkedImageRegex, (_, alt, src, title, href) => {
const imgTag = createImgTag(alt, src, title)
return `<a href="${escapeHTML(href)}">${imgTag}</a>`
})
// 再处理普通图片
result = result.replace(imageRegex, (_, alt, src, title) => {
return createImgTag(alt, src, title)
})
return result
}
// 辅助函数:创建 img 标签
function createImgTag(alt: string, src: string, title?: string): string {
const escapedSrc = escapeHTML(src)
const escapedAlt = escapeHTML(alt)
const titleAttr = title ? ` title="${escapeHTML(title)}"` : ''
return `<img src="${escapedSrc}" alt="${escapedAlt}"${titleAttr}>`
}转义 HTML 特殊字符
/**
* 转义 HTML 特殊字符
*/
function escapeHTML(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}代码高亮

安装依赖
npm i highlight.js导入样式
src/renderer/src/main.ts
import 'highlight.js/styles/atom-one-dark.min.css'导入使用
src/renderer/src/App.vue
import hljs from 'highlight.js'// 监听 parsedMarkdown 的变化,在内容更新后执行代码高亮和添加复制按钮
watch(parsedMarkdown, () => {
nextTick(() => {
const codeBlocks = document.querySelectorAll('pre code')
codeBlocks.forEach((block) => {
hljs.highlightElement(block as HTMLElement)
addCopyButton(block as HTMLElement)
})
})
})一键复制代码

// 监听 parsedMarkdown 的变化,在内容更新后执行代码高亮和添加复制按钮
watch(parsedMarkdown, () => {
nextTick(() => {
const codeBlocks = document.querySelectorAll('pre code')
codeBlocks.forEach((block) => {
hljs.highlightElement(block as HTMLElement)
addCopyButton(block as HTMLElement)
})
})
})// 在代码块中添加复制按钮
const addCopyButton = (codeBlock: HTMLElement): void => {
const preElement = codeBlock.parentElement
if (preElement && !preElement.querySelector('.copy-button')) {
const copyButton = document.createElement('button')
copyButton.textContent = '复制'
copyButton.className = 'copy-button'
copyButton.addEventListener('click', copyCode.bind(null, codeBlock, copyButton))
preElement.style.position = 'relative'
preElement.appendChild(copyButton)
}
}相关样式
.copy-button {
position: absolute;
top: 4px;
right: 4px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background-color: #f0f0f0;
cursor: pointer;
font-size: 12px;
opacity: 0.8;
z-index: 1; /* 确保按钮显示在代码块上方 */
}
.copy-button:hover {
opacity: 1;
}点击超链接时,用本地浏览器打开
const handle_preview_click = async (event: Event): Promise<void> => {
const target = event.target as HTMLElement
if (target.tagName === 'A') {
const href = target.getAttribute('href')
if (href && href.startsWith('http')) {
event.preventDefault()
window.electron.ipcRenderer.send('open-link', href)
}
}src/main/ipc.ts
ipcMain.on('open-link', async (_e, href) => {
// 使用操作系统默认浏览器打开链接
shell.openExternal(href)
})点击待办列表,同步改动编辑区

const handle_preview_click = async (event: Event): Promise<void> => {
const target = event.target as HTMLElement
if (target.tagName === 'A') {
const href = target.getAttribute('href')
if (href && href.startsWith('http')) {
event.preventDefault()
window.electron.ipcRenderer.send('open-link', href)
}
}
// 点击待办列表,同步改动编辑区
else if (target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox') {
const isChecked = (target as HTMLInputElement).checked
// 找到包含待办列表项的 label 元素
const label = target.closest('.todo-item') as HTMLLabelElement
// 获取自定义属性 line_index
const lineIndex = label.getAttribute('data-line_index')
if (lineIndex) {
const currentContent = markdownContent.value
const lines = currentContent.split('\n')
lines[lineIndex] = lines[lineIndex].replace(/-\s\[[ xX]\]\s/, isChecked ? '- [x] ' : '- [ ] ')
markdownContent.value = lines.join('\n')
}
}
}编辑区滚动时,同步滚动预览区
@scroll="syncPreviewScroll"
></textarea>// 同步预览区滚动
const syncPreviewScroll = (): void => {
// 点击大纲项时,不触发同步预览区滚动
if (editorRef.value && previewRef.value && !ifClickOutLine.value) {
const editor = editorRef.value
const preview = previewRef.value
const editorScrollRatio = editor.scrollTop / (editor.scrollHeight - editor.clientHeight)
const previewScrollTop = editorScrollRatio * (preview.scrollHeight - preview.clientHeight)
preview.scrollTop = previewScrollTop
}
}预览区滚动时,同步更新高亮大纲
@scroll="update_currentOutlineId"// 更新当前高亮大纲
const update_currentOutlineId = (): void => {
if (ifClickOutLine.value) {
return
}
if (!previewRef.value) return
const headings = previewRef.value.querySelectorAll('h1, h2, h3, h4, h5, h6')
let lastId = ''
for (const heading of headings) {
// 当标题即将超出预览区域时,停止查找更新
if ((heading as HTMLElement).offsetTop - 60 >= previewRef.value.scrollTop) {
break
}
lastId = heading.id
}
currentOutLineID.value = lastId
}md 转为 html 后的专用样式
先重置浏览器的默认样式,再添加特有样式
src/renderer/src/assets/reset.css
html,
body {
margin: 0;
padding: 0;
}
/* 改进 heading 元素的默认样式 */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
p {
margin-block-end: 0px;
margin-block-start: 0px;
line-height: 1;
margin: 0;
min-height: 0;
}
/* 改进列表元素的默认样式 */
ul,
ol {
margin-top: 10px;
margin-block-end: 0px;
margin-block-start: 10px;
line-height: 1;
padding-inline-start: 20px;
}
/* 改进表单元素的默认样式 */
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
select {
text-transform: none;
}
/* 改进图片元素的默认样式 */
img {
max-width: 100%;
height: auto;
display: block;
}
/* 改进表格元素的默认样式 */
table {
border-collapse: collapse;
}
/* 改进块级引用元素的默认样式 */
blockquote {
margin: 0;
}
/* 改进代码元素的默认样式 */
pre,
code,
kbd,
samp {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 1em;
}
code {
padding-top: 2em !important;
}
/* 改进表单元素的默认样式 */
fieldset {
margin: 0;
padding: 0;
border: 0;
}
/* 改进输入框的焦点样式 */
:focus {
outline: 2px solid #4f46e5; /* 紫色调,符合现代设计趋势 */
outline-offset: 2px;
}
/* 改进滚动行为 */
html {
scroll-behavior: smooth;
}
/* 改进选中状态的背景色 */
::selection {
background-color: #3b82f6; /* 蓝色调,符合现代设计趋势 */
color: white;
}src/renderer/src/assets/md.css
code {
font-family: Consolas;
border-radius: 4px;
white-space: pre;
line-height: 1.5;
word-break: break-all;
scrollbar-width: thin;
}
pre {
position: relative; /* 确保 pre 元素作为定位参考 */
}
.copy-button {
position: absolute;
top: 4px;
right: 4px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background-color: #f0f0f0;
cursor: pointer;
font-size: 12px;
opacity: 0.8;
z-index: 1; /* 确保按钮显示在代码块上方 */
}
.copy-button:hover {
opacity: 1;
}
p {
min-height: 1.5em; /* 设置最小高度,确保即使没有内容也有高度 */
}
/* Markdown 表格样式 */
.markdown-table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-family: Arial, sans-serif;
}
/* 表头样式 */
.markdown-table th {
background-color: #f7f7f7;
border: 1px solid #ddd;
padding: 8px 12px;
font-weight: bold;
color: #333;
}
/* 单元格样式 */
.markdown-table td {
border: 1px solid #ddd;
padding: 8px 12px;
color: #555;
}
/* 交替行背景色 */
.markdown-table tr:nth-child(even) {
background-color: #f9f9f9;
}
/* 悬停效果 */
.markdown-table tr:hover {
background-color: #f5f5f5;
transition: background-color 0.2s;
}
/* 对齐方式 */
.markdown-table .align-center {
text-align: center;
}
.markdown-table .align-right {
text-align: right;
}
.markdown-table .align-left {
text-align: left;
}
/*引用*/
blockquote {
border-left: 4px solid #12ab77;
padding: 10px 15px;
display: flex;
flex-direction: column;
color: rgb(106, 103, 103);
background-color: #d0f7e9;
margin: 10px 0px;
border-radius: 5px;
}

所有评论(0)