乐奇 Rokid 开放社区 Electron-vite【实战】MD 编辑器 -- 预览区(含自研 Markdown 语法解析器,代码高亮和一键复制,本地浏览器打开超链接,待办列表同步修改,滚动同步高亮大纲,与编辑器同步滚动等)

Electron-vite【实战】MD 编辑器 -- 预览区(含自研 Markdown 语法解析器,代码高亮和一键复制,本地浏览器打开超链接,待办列表同步修改,滚动同步高亮大纲,与编辑器同步滚动等)

最终效果

Electron-vite【实战】MD 编辑器 -- 预览区(含自研 Markdown 语法解析器,代码高亮和一键复制,本地浏览器打开超链接,待办列表同步修改,滚动同步高亮大纲,与编辑器同步滚动等)_编辑器

页面

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, ''')
}

代码高亮

Electron-vite【实战】MD 编辑器 -- 预览区(含自研 Markdown 语法解析器,代码高亮和一键复制,本地浏览器打开超链接,待办列表同步修改,滚动同步高亮大纲,与编辑器同步滚动等)_HTML_02

安装依赖

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)
    })
  })
})

一键复制代码

Electron-vite【实战】MD 编辑器 -- 预览区(含自研 Markdown 语法解析器,代码高亮和一键复制,本地浏览器打开超链接,待办列表同步修改,滚动同步高亮大纲,与编辑器同步滚动等)_Markdown_03

// 监听 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)
  })

点击待办列表,同步改动编辑区

Electron-vite【实战】MD 编辑器 -- 预览区(含自研 Markdown 语法解析器,代码高亮和一键复制,本地浏览器打开超链接,待办列表同步修改,滚动同步高亮大纲,与编辑器同步滚动等)_编辑器_04

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;
}

更多推荐

  • 浏览量 446
  • 收藏 0
  • 0

所有评论(0)

查看更多评论 
已为社区贡献6条内容