实现代码

<template>
  <div class="markdown-editor">
    <div class="editor-container">
      <!-- Markdown 输入区域 -->
      <div class="markdown-input" v-show="!isPreviewMode && isMarkdown">
        <el-card class="editor-card">
          <template #header>
            <div class="header">
              <div class="toolbar">
                <!-- 导出文件 -->
                <el-button-group>
                  <el-button size="small" @click="exportFile" title="导出文件">
                    <el-icon>
                      <Download />
                    </el-icon>
                  </el-button>
                </el-button-group>

                <!-- 格式化工具 -->
                <el-button-group>
                  <el-button size="small" @click="insertMarkdown('heading')" title="标题">
                    <strong>H</strong>
                  </el-button>
                  <el-button size="small" @click="insertMarkdown('bold')" title="加粗">
                    <strong>B</strong>
                  </el-button>
                  <el-button size="small" @click="insertMarkdown('italic')" title="斜体">
                    <em>I</em>
                  </el-button>
                  <el-button size="small" @click="insertMarkdown('quote')" title="引用">
                    <el-icon>
                      <ChatLineSquare />
                    </el-icon>
                  </el-button>
                  <el-button size="small" @click="insertMarkdown('code')" title="代码">
                    <el-icon>
                      <Notebook />
                    </el-icon>
                  </el-button>
                  <el-button size="small" @click="insertMarkdown('link')" title="链接">
                    <el-icon>
                      <Link />
                    </el-icon>
                  </el-button>
                  <el-button size="small" @click="insertMarkdown('image')" title="图片">
                    <el-icon>
                      <Picture />
                    </el-icon>
                  </el-button>
                  <el-button size="small" @click="insertMarkdown('list')" title="列表">
                    <el-icon>
                      <List />
                    </el-icon>
                  </el-button>
                </el-button-group>

                <!-- 切换到HTML -->
                <el-button-group>
                  <el-button size="small" @click="toggleToHtml" title="切换到HTML">
                    <el-icon>
                      <Document />
                    </el-icon>
                    切换到HTML
                  </el-button>
                </el-button-group>
                <!-- 预览控制 -->
                <el-button-group>
                  <el-button size="small" @click="togglePreview" title="预览模式" :type="isPreviewMode ? 'primary' : ''"
                    class="preview-button">
                    <el-icon>
                      <View />
                    </el-icon>
                    预览
                  </el-button>
                </el-button-group>
              </div>
            </div>
          </template>
          <el-input v-model="markdownContent" type="textarea" :rows="20" placeholder="请输入 Markdown 内容..."
            @input="handleContentChange" />
        </el-card>
      </div>
      <!-- HTML 输入区域 -->
      <div class="markdown-input" v-show="!isPreviewMode && !isMarkdown">
        <el-card class="editor-card">
          <template #header>
            <div class="header">
              <span>HTML 输入区域</span>
              <div class="toolbar">
                <!-- 切换到Markdown -->
                <el-button-group>
                  <el-button size="small" @click="toggleToMarkdown" title="切换到Markdown">
                    <el-icon>
                      <Notebook />
                    </el-icon>
                    切换到Markdown
                  </el-button>
                </el-button-group>

                <!-- 预览控制 -->
                <el-button-group>
                  <el-button size="small" @click="togglePreview" title="预览模式" :type="isPreviewMode ? 'primary' : ''"
                    class="preview-button">
                    <el-icon>
                      <View />
                    </el-icon>
                    预览
                  </el-button>
                </el-button-group>
              </div>
            </div>
          </template>
          <el-input v-model="htmlContent" type="textarea" :rows="20" placeholder="请输入 HTML 内容..."
            @input="handleHtmlChange" />
        </el-card>
      </div>

      <!-- HTML 预览区域 -->
      <div class="preview-container">
        <el-card class="preview-card">
          <template #header>
            <div class="header">
              <span v-show="!isPreviewMode">HTML 预览</span>
              <el-button v-show="isPreviewMode" size="small" @click="togglePreview" title="退出预览"
                class="preview-exit-button">
                <!-- <el-icon>
                    <Close />
                  </el-icon> -->
                退出预览
              </el-button>
              <div style="display: flex; gap: 10px;align-items: center;">
                <el-select v-model="currentStyle" size="small" placeholder="选择样式" @change="setStyle"
                  style="width: 120px;margin-right: 10px;">
                  <el-option label="样式1" value="style1"></el-option>
                  <el-option label="样式2" value="style2"></el-option>
                </el-select>
                <!-- 复制到公众号 -->
                <el-button size="small" @click="copyToWechat" title="复制到公众号">
                  <el-icon>
                    <Document />
                  </el-icon>
                  复制到公众号
                </el-button>
                <!-- 导出为图片 -->
                <el-button size="small" @click="exportAsImage" title="导出为图片">
                  <el-icon>
                    <Picture />
                  </el-icon>
                  导出为图片
                </el-button>
              </div>
            </div>
          </template>
          <div class="preview-content">
            <div class="card" ref="card">
              <div class="card-header">
              </div>
              <div class="card-content">
                <div class="card-content-inner" v-html="htmlContent"></div>
              </div>
              <div class="card-footer"></div>
            </div>
          </div>

        </el-card>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { marked } from 'marked'
import { ElMessage } from 'element-plus'
import html2canvas from 'html2canvas'
// 使用 ESM 版本的 html2canvas
// import html2canvas from 'html2canvas/dist/html2canvas.esm.js'
import {
  Download, ChatLineSquare, Notebook, Link,
  Picture, List, View, FullScreen, Document, Close
} from '@element-plus/icons-vue'

// 编辑器状态
const markdownContent = ref('')

const currentStyle = ref('style1') // 默认选择样式1
const htmlContent = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const isPreviewMode = ref(false)
const isMarkdown = ref(true) // 默认显示 Markdown 编辑器

const card = ref<HTMLElement | null>(null) // 添加对预览区域的引用,并指定正确的类型

// 编辑历史
const history = reactive({
  past: [] as string[],
  future: [] as string[]
})

// 文件操作函数
const exportFile = () => {
  const blob = new Blob([markdownContent.value], { type: 'text/markdown' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'markdown.md'
  a.click()
  URL.revokeObjectURL(url)
}

// 编辑历史操作
const saveHistory = () => {
  history.past.push(markdownContent.value)
  history.future = []
  if (history.past.length > 50) {
    history.past.shift()
  }
}

// 预览控制
const togglePreview = () => {
  isPreviewMode.value = !isPreviewMode.value
}

// 切换到Markdown模式
const toggleToMarkdown = () => {
  isMarkdown.value = true
}

let cssData1 = `
.card {
  max-width: 420px;
  background: #ffffff;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
  font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
  line-height: 1.65;
  color: #333;
  margin: 24px auto;
  transition: all 0.3s ease;
}

.card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}

.card-header {
  background: #ff2442;
  height: 6px;
  border-radius: 16px 16px 0 0;
}

.card-content {
  padding: 32px;
}

.card-content-inner {
  padding: 0;
}

.card-content-inner > *:first-child {
  margin-top: 0;
}

.card-content-inner > *:last-child {
  margin-bottom: 0;
}

.card-content-inner h1 {
  font-size: 24px;
  font-weight: 700;
  margin: 0 0 24px;
  color: #1a1a1a;
  letter-spacing: -0.2px;
  line-height: 1.4;
  position: relative;
  padding-bottom: 16px;
}

.card-content-inner h1:after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  width: 36px;
  height: 3px;
  background: #ff2442;
  border-radius: 2px;
}

.card-content-inner h2 {
  font-size: 20px;
  font-weight: 600;
  margin: 32px 0 20px;
  color: #2c2c2c;
  padding-bottom: 8px;
  border-bottom: 1px solid #f5f5f5;
}

.card-content-inner p {
  font-size: 16px;
  margin: 0 0 24px;
  color: #444;
  text-align: justify;
  hyphens: auto;
}

.card-content-inner ol,
.card-content-inner ul {
  padding-left: 24px;
  margin: 0 0 24px;
}

.card-content-inner ol li,
.card-content-inner ul li {
  margin-bottom: 12px;
  padding-left: 8px;
}

.card-content-inner ol li {
  position: relative;
  counter-increment: list-counter;
}

.card-content-inner ol li::before {
  content: counter(list-counter);
  position: absolute;
  left: -26px;
  top: 2px;
  width: 20px;
  height: 20px;
  background: #ffebee;
  color: #ff2442;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  font-weight: 600;
}

.card-content-inner ul li::before {
  content: "•";
  color: #ff2442;
  font-weight: bold;
  display: inline-block;
  width: 1em;
  margin-left: -1em;
}

.card-content-inner strong {
  color: #ff2442;
  font-weight: 600;
}

.card-content-inner a {
  color: #ff2442;
  text-decoration: none;
  border-bottom: 1px solid rgba(255, 36, 66, 0.3);
  transition: all 0.2s ease;
}

.card-content-inner a:hover {
  color: #e01e3c;
  border-bottom-color: #e01e3c;
}

.card-content-inner code {
  background: #fff0f2;
  padding: 2px 6px;
  border-radius: 4px;
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  font-size: 14px;
  color: #ff2442;
}

.card-content-inner pre {
  background: #fffafb;
  padding: 18px;
  border-radius: 8px;
  overflow-x: auto;
  margin: 0 0 24px;
  font-size: 14px;
  line-height: 1.5;
  border-left: 3px solid #ff2442;
}

.card-content-inner pre code {
  background: none;
  padding: 0;
  color: #444;
  font-size: 14px;
}

.card-content-inner blockquote {
  border-left: 3px solid #ffcdd2;
  padding: 4px 20px 4px 20px;
  margin: 0 0 24px;
  color: #666;
  background: #fffafa;
  border-radius: 0 8px 8px 0;
  font-style: italic;
}

.card-content-inner hr {
  border: 0;
  height: 1px;
  background: linear-gradient(to right, rgba(255, 36, 66, 0.1), transparent);
  margin: 32px 0;
}

.card-footer {
  padding: 16px 32px;
  background: #fffafa;
  border-top: 1px solid #f9f0f0;
  color: #999;
  font-size: 13px;
  display: flex;
  justify-content: space-between;
}

/* 小红书特色元素 */
.card-content-inner h1 + p {
  font-size: 17px;
  color: #666;
  margin-top: -8px;
  margin-bottom: 28px;
}

.card-content-inner img {
  max-width: 100%;
  border-radius: 12px;
  margin: 24px 0;
  display: block;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}

/* 留白增强 */
.card-content-inner p + h2 {
  margin-top: 36px;
}

.card-content-inner ul + h2,
.card-content-inner ol + h2 {
  margin-top: 40px;
}
`
let cssData2 = `
.card {
  max-width: 680px;
  background: #ffffff;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
  line-height: 1.7;
  color: #333;
  margin: 40px auto;
  transition: transform 0.3s ease;
}

.card:hover {
  transform: translateY(-3px);
}

.card-header {
  background: linear-gradient(135deg, #6e8efb, #a777e3);
  height: 6px;
  border-radius: 12px 12px 0 0;
}

.card-content {
  padding: 40px;
}

.card-content-inner {
  padding: 0;
}

.card-content-inner > *:first-child {
  margin-top: 0;
}

.card-content-inner > *:last-child {
  margin-bottom: 0;
}

.card-content-inner h1 {
  font-size: 28px;
  font-weight: 700;
  margin: 0 0 30px;
  color: #1a1a1a;
  letter-spacing: -0.01em;
  line-height: 1.3;
}

.card-content-inner h2 {
  font-size: 22px;
  font-weight: 600;
  margin: 40px 0 20px;
  color: #2c2c2c;
  padding-bottom: 8px;
  border-bottom: 1px solid #f0f0f0;
}

.card-content-inner h3 {
  font-size: 18px;
  font-weight: 600;
  margin: 35px 0 15px;
  color: #3a3a3a;
}

.card-content-inner p {
  font-size: 17px;
  margin: 0 0 28px;
  color: #444;
  text-align: justify;
  hyphens: auto;
}

.card-content-inner ol,
.card-content-inner ul {
  padding-left: 24px;
  margin: 0 0 28px;
}

.card-content-inner ol li,
.card-content-inner ul li {
  margin-bottom: 12px;
  padding-left: 12px;
}

.card-content-inner ol li {
  position: relative;
  counter-increment: list-counter;
}

.card-content-inner ol li::before {
  content: counter(list-counter);
  position: absolute;
  left: -24px;
  top: 0;
  width: 24px;
  height: 24px;
  background: #f5f7ff;
  color: #6e8efb;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 500;
}

.card-content-inner ul li::before {
  content: "•";
  color: #a777e3;
  font-weight: bold;
  display: inline-block;
  width: 1em;
  margin-left: -1em;
}

.card-content-inner strong {
  color: #2c2c2c;
  font-weight: 600;
}

.card-content-inner em {
  font-style: italic;
  color: #555;
}

.card-content-inner a {
  color: #6e8efb;
  text-decoration: none;
  border-bottom: 1px solid rgba(110, 142, 251, 0.3);
  transition: all 0.2s ease;
}

.card-content-inner a:hover {
  color: #a777e3;
  border-bottom-color: #a777e3;
}

.card-content-inner code {
  background: #f8f9ff;
  padding: 3px 6px;
  border-radius: 4px;
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  font-size: 15px;
  color: #6e8efb;
}

.card-content-inner pre {
  background: #f8f9ff;
  padding: 20px;
  border-radius: 8px;
  overflow-x: auto;
  margin: 0 0 30px;
  font-size: 15px;
  line-height: 1.5;
  border-left: 3px solid #a777e3;
}

.card-content-inner pre code {
  background: none;
  padding: 0;
  color: #444;
  font-size: 15px;
}

.card-content-inner blockquote {
  border-left: 3px solid #e0e0e0;
  padding: 4px 20px 4px 24px;
  margin: 0 0 30px;
  color: #555;
  font-style: italic;
  background: #fafbff;
  border-radius: 0 8px 8px 0;
}

.card-content-inner hr {
  border: 0;
  height: 1px;
  background: #f0f0f0;
  margin: 40px 0;
}

.card-footer {
  padding: 20px 40px;
  background: #fafbff;
  border-top: 1px solid #f0f0f0;
  color: #777;
  font-size: 14px;
  display: flex;
  justify-content: space-between;
}

/* 留白增强 */
.card-content-inner p + h2,
.card-content-inner ul + h2,
.card-content-inner ol + h2 {
  margin-top: 50px;
}

.card-content-inner p + h3 {
  margin-top: 40px;
}

.card-content-inner img {
  max-width: 100%;
  border-radius: 8px;
  margin: 30px 0;
  display: block;
}
`
const setStyle = () => {
  // 移除所有旧的样式标签
  const oldStyles = document.querySelectorAll('style[data-md-style]')
  oldStyles.forEach(style => style.remove())

  // 创建新的样式标签
  const styleTag = document.createElement('style')
  styleTag.type = 'text/css'
  styleTag.setAttribute('data-md-style', 'true') // 添加标识,方便后续删除

  // 根据选择的样式设置内容
  styleTag.innerHTML = currentStyle.value === 'style1' ? cssData1 : cssData2

  // 插入到页面头部
  document.head.appendChild(styleTag)
}



// 配置marked选项
marked.setOptions({
  breaks: true, // 将回车转换为 <br>
  gfm: true, // 启用 GitHub 风格的 Markdown
  // sanitize: false, // 允许HTML标签
})

// 使用marked进行Markdown转HTML
const convertMarkdownToHtml = (markdown: string): any => {
  return marked(markdown)
}

// 处理 Markdown 内容变化
const handleContentChange = () => {
  saveHistory()
  if (isMarkdown.value) {
    updatePreview()
  }
}

// 处理 HTML 内容变化
const handleHtmlChange = () => {
  // HTML 编辑器内容变化时不需要转换,直接保存历史
  saveHistory()
}

// 更新预览
const updatePreview = () => {
  if (isMarkdown.value) {
    htmlContent.value = convertMarkdownToHtml(markdownContent.value)
  }
}

// 切换到 HTML 模式时的处理
const toggleToHtml = () => {
  isMarkdown.value = false
  // 如果 HTML 内容为空,则使用当前 Markdown 转换的结果
  if (!htmlContent.value.trim()) {
    htmlContent.value = convertMarkdownToHtml(markdownContent.value)
  }
}

// 在光标位置插入Markdown语法
const insertMarkdown = (type: string) => {
  const textarea = document.querySelector('.el-textarea__inner') as HTMLTextAreaElement
  if (!textarea) return

  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  const selected = markdownContent.value.substring(start, end)

  let insertion = ''

  switch (type) {
    case 'bold':
      insertion = `**${selected || '粗体文本'}**`
      break
    case 'italic':
      insertion = `*${selected || '斜体文本'}*`
      break
    case 'heading':
      insertion = `\n## ${selected || '标题'}\n`
      break
    case 'link':
      insertion = `[${selected || '链接文本'}](https://example.com)`
      break
    case 'list':
      insertion = `\n1.  ${selected || '列表项'}\n2.  列表项\n3.  列表项\n`
      break
    case 'quote':
      insertion = `\n> ${selected || '引用文本'}\n`
      break
    case 'code':
      insertion = selected ?
        `\`\`\`\n${selected}\n\`\`\`` :
        `\`\`\`\n代码块\n\`\`\``
      break
    case 'image':
      insertion = `![${selected || '图片描述'}](https://example.com/image.jpg)`
      break
    default:
      return
  }

  const newContent =
    markdownContent.value.substring(0, start) +
    insertion +
    markdownContent.value.substring(end)

  markdownContent.value = newContent

  // 保存历史记录
  saveHistory()

  // 更新预览
  updatePreview()

  // 聚焦回文本区域
  setTimeout(() => {
    textarea.focus()
    const newCursorPos = start + insertion.length
    textarea.setSelectionRange(newCursorPos, newCursorPos)
  }, 0)
}

// 复制HTML到剪贴板
const copyHtml = () => {
  navigator.clipboard.writeText(htmlContent.value)
    .then(() => {
      ElMessage({
        message: 'HTML已复制到剪贴板',
        type: 'success',
        duration: 2000
      })
    })
    .catch(err => {
      ElMessage({
        message: '复制失败: ' + err,
        type: 'error',
        duration: 2000
      })
    })
}


// 导出为图片
const exportAsImage = async () => {
  try {
    // 显示加载提示
    ElMessage({
      message: '正在生成图片,请稍候...',
      type: 'info',
      duration: 0,
      showClose: true
    })

    // 等待一小段时间确保DOM已完全渲染
    await new Promise(resolve => setTimeout(resolve, 100))

    const element = card.value
    if (!element) {
      ElMessage.error('获取预览内容失败')
      return
    }

    console.log('开始导出图片,元素:', element)

    // 确保样式已应用
    const styleTag = document.querySelector('style[data-md-style]')
    if (!styleTag) {
      setStyle() // 如果没有样式,先应用样式
      // 再等待一小段时间确保样式已应用
      await new Promise(resolve => setTimeout(resolve, 100))
    }

    // 创建一个临时容器,用于克隆和渲染
    const tempContainer = document.createElement('div')
    tempContainer.style.position = 'absolute'
    tempContainer.style.left = '-9999px'
    tempContainer.style.top = '0'
    tempContainer.style.width = `${element.offsetWidth}px`
    tempContainer.style.height = `${element.offsetHeight}px`

    // 克隆元素
    const clone = element.cloneNode(true) as HTMLElement
    tempContainer.appendChild(clone)
    document.body.appendChild(tempContainer)

    // 创建canvas,添加更多配置选项
    const canvas = await html2canvas(clone, {
      scale: 2, // 提高图片质量
      useCORS: true, // 允许加载跨域图片
      allowTaint: true, // 允许加载跨域图片(即使可能污染画布)
      backgroundColor: '#ffffff', // 设置背景色
      logging: true, // 开启日志以便调试
      foreignObjectRendering: false, // 禁用foreignObject渲染,提高兼容性
      removeContainer: true, // 移除临时创建的容器
      onclone: (clonedDoc) => {
        // 确保克隆的文档中包含所有必要的样式
        const styles = document.querySelectorAll('style[data-md-style]')
        styles.forEach(style => {
          clonedDoc.head.appendChild(style.cloneNode(true))
        })
        console.log('文档已克隆,添加了样式')
      }
    })

    // 清理临时容器
    document.body.removeChild(tempContainer)

    console.log('Canvas创建成功:', canvas)

    // 关闭加载提示
    ElMessage.closeAll()

    // 转换为图片并下载
    const link = document.createElement('a')
    link.download = 'markdown-card.png'
    link.href = canvas.toDataURL('image/png', 1.0)
    document.body.appendChild(link) // 添加到DOM以确保在某些浏览器中正常工作
    link.click()
    document.body.removeChild(link) // 清理DOM

    ElMessage.success('图片导出成功')
  } catch (error:any) {
    // 关闭加载提示
    ElMessage.closeAll()
    console.error('Export failed:', error)
    ElMessage.error(`图片导出失败: ${error.message}`)
  }
}

// 解析样式表中的伪元素样式
const parsePseudoElementStyles = () => {
  const pseudoStyles: { [selector: string]: { [property: string]: string } } = {}

  // 遍历所有样式表
  for (let i = 0; i < document.styleSheets.length; i++) {
    try {
      const styleSheet = document.styleSheets[i]
      // 跳过跨域样式表
      if (!styleSheet.cssRules) continue

      // 遍历所有CSS规则
      for (let j = 0; j < styleSheet.cssRules.length; j++) {
        const rule = styleSheet.cssRules[j]
        if (rule instanceof CSSStyleRule) {
          // 检查是否包含::before或::after伪元素
          if (rule.selectorText.includes('::before') || rule.selectorText.includes('::after')) {
            const selector = rule.selectorText
            const pseudoType = selector.includes('::before') ? '::before' : '::after'

            // 提取基本选择器(去掉伪元素部分)
            const baseSelector = selector.split(pseudoType)[0].trim()

            // 存储伪元素样式
            if (!pseudoStyles[selector]) {
              pseudoStyles[selector] = {}
            }

            // 提取样式属性
            for (let k = 0; k < rule.style.length; k++) {
              const property = rule.style[k]
              const value = rule.style.getPropertyValue(property)
              pseudoStyles[selector][property] = value
            }

            // 特别处理content属性
            const content = rule.style.getPropertyValue('content')
            if (content) {
              // 去掉引号
              pseudoStyles[selector]['content'] = content.replace(/^["']|["']$/g, '')
            }
          }
        }
      }
    } catch (e) {
      // 忽略跨域错误
      console.warn('无法访问样式表:', e)
    }
  }

  return pseudoStyles
}

// 处理CSS计数器函数
const processCounterFunction = (content: string, element: Element, index: number): string => {
  // 检查是否包含counter函数
  if (content.includes('counter(')) {
    // 处理有序列表的计数器
    if (element.parentElement?.tagName === 'OL') {
      // 获取列表项的索引(从1开始)
      const listIndex = index + 1
      // 替换counter函数为实际数字
      return listIndex.toString()
    }
  }
  return content
}

// 将伪元素转换为实际DOM元素
const convertPseudoElementsToDOM = (element: HTMLElement, pseudoStyles: { [selector: string]: { [property: string]: string } }) => {
  // 处理所有伪元素选择器
  Object.keys(pseudoStyles).forEach(selector => {
    const pseudoType = selector.includes('::before') ? '::before' : '::after'

    try {
      // 处理可能包含逗号的复杂选择器
      const selectorParts = selector.split(',').map(part => part.trim())

      selectorParts.forEach(part => {
        if (part.includes(pseudoType)) {
          // 提取基本选择器(去掉伪元素部分)
          const baseSelector = part.split(pseudoType)[0].trim()

          // 跳过通用选择器和无效选择器
          if (baseSelector === '*' || !baseSelector) {
            return
          }

          try {
            // 查找匹配的元素
            const matchingElements = element.querySelectorAll(baseSelector)

            matchingElements.forEach((el, index) => {
              // 创建伪元素的实际DOM表示
              const pseudoElement = document.createElement('span')
              pseudoElement.className = `pseudo-element-${pseudoType.replace('::', '')}`

              // 应用样式
              const styles = pseudoStyles[selector]
              Object.keys(styles).forEach(property => {
                if (property !== 'content') {
                  (pseudoElement.style as any)[property] = styles[property]
                }
              })

              // 设置内容
              if (styles['content'] && styles['content'] !== 'none') {
                // 处理特殊的content值,如counter()函数
                let contentValue = styles['content']

                // 处理counter函数
                if (contentValue.includes('counter(')) {
                  contentValue = processCounterFunction(contentValue, el, index)
                }

                pseudoElement.textContent = contentValue
              }

              // 插入到正确的位置
              if (pseudoType === '::before') {
                el.insertBefore(pseudoElement, el.firstChild)
              } else {
                el.appendChild(pseudoElement)
              }
            })
          } catch (e) {
            console.warn(`无法处理基本选择器 ${baseSelector}:`, e)
          }
        }
      })
    } catch (e) {
      console.warn(`无法处理选择器 ${selector}:`, e)
    }
  })
}

// 复制到公众号
const copyToWechat = () => {
  // 获取预览区域的内容
  const previewContent = document.querySelector('.card-content-inner')
  if (!previewContent) {
    ElMessage.error('获取预览内容失败')
    return
  }

  try {
    // 创建一个临时div来存放富文本内容
    const tempDiv = document.createElement('div')

    // 克隆内容以便处理
    const clonedContent = previewContent.cloneNode(true) as HTMLElement

    // 解析当前页面中的伪元素样式
    const pseudoStyles = parsePseudoElementStyles()

    // 将伪元素转换为实际DOM元素
    convertPseudoElementsToDOM(clonedContent, pseudoStyles)

    // 获取当前应用的样式表内容
    const styleTag:any = document.querySelector('style[data-md-style]')
    const styleContent = styleTag ? styleTag.innerHTML : ''

    // 创建包含样式和内容的HTML
    tempDiv.innerHTML = `
      <style>
        ${styleContent}
        .card {
          max-width: none !important;
          width: 100% !important;
        }
        /* 隐藏原始的伪元素 */
        [class^="pseudo-element-"] {
          display: inline-block;
        }
        /* 隐藏原始的伪元素 */
        *::before, *::after {
          display: none !important;
        }
      </style>
      <div class="card-content-inner">
        ${clonedContent.innerHTML}
      </div>
    `

    // 应用必要的样式使元素不可见
    tempDiv.style.position = 'fixed'
    tempDiv.style.left = '-9999px'
    tempDiv.style.top = '0'
    tempDiv.style.opacity = '0'
    document.body.appendChild(tempDiv)

    // 创建选择范围
    const range = document.createRange()
    range.selectNode(tempDiv)

    // 清除当前选择
    window.getSelection()?.removeAllRanges()

    // 添加新的选择范围
    window.getSelection()?.addRange(range)

    // 执行复制命令
    const successful = document.execCommand('copy')

    // 清理
    window.getSelection()?.removeAllRanges()
    document.body.removeChild(tempDiv)

    if (successful) {
      ElMessage.success('已复制到剪贴板,可直接粘贴到公众号')
    } else {
      throw new Error('复制命令执行失败')
    }
  } catch (error) {
    console.error('Copy failed:', error)
    ElMessage.error('复制失败,请重试')
  }
}

// 组件挂载时初始化
onMounted(() => {
  // 设置示例Markdown内容
  markdownContent.value = `
  # MD2Card

> MD2Card 是一个 markdown 转知识卡片工具,可以让你用 Markdown 制作优雅的图文海报。 🌟

![](https://picsum.photos/600/300)


## 它的主要功能:

1. 将 Markdown 转化为**知识卡片**
2. 多种主题风格任你选择
3. 长文自动拆分,或者根据 markdown --- 横线拆分
4. 可以复制图片到剪贴板,或者下载为PNG、SVG图片
5. 所见即所得
6. 免费
`

  updatePreview()
  // 初始化应用默认样式
  setStyle()
})
</script>

<style scoped lang="scss">
.markdown-editor {
  height: calc(100vh - 40px);
  box-sizing: border-box;
}

.markdown-editor.fullscreen {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 9999;
  padding: 0;
  background-color: #fff;
}

.editor-container {
  height: 100%;
  display: flex;

  .markdown-input,
  .preview-container {
    flex: 1;
    height: 100%;
    overflow-y: auto;
  }

  .style-card {
    width: 260px;
  }
}

.editor-card,
.preview-card {
  height: 100%;
  position: relative;
  transition: all 0.3s ease;
  display: flex;
  flex-direction: column;

  .preview-content {
    height: auto;
  }

  :deep(.el-card__body) {
    flex: 1;
    overflow: auto;
    padding: 0;
    display: flex;
    flex-direction: column;
  }

  /* Editor card specific styles */
  &.editor-card {
    :deep(.el-textarea) {
      flex: 1;
      display: flex;
      flex-direction: column;

      .el-textarea__inner {
        flex: 1;
        resize: none;
        border: none;
        box-shadow: none;
        padding: 20px;
      }
    }
  }

  /* Preview card specific styles */
  &.preview-card {
    height: 100%;

    :deep(.el-card__body) {
      // height: 100%;
      // overflow: auto;
      padding: 0;
      display: flex;
      flex-direction: column;
    }

    .card {
      flex: 1;
      min-height: 0;
      /* 修复flex容器滚动问题 */
      padding: 20px;
    }

    .card-content {
      height: auto;
    }
  }

  /* Style card specific styles */
  &.style-card {
    .style-content {
      flex: 1;
      overflow: auto;
      padding: 10px;

      .style-item {
        padding: 8px 12px;
        margin-bottom: 8px;
        border: 1px solid #e0deed;
        color: #0f0a29;
        background: #fff;
        border-radius: 4px;
        cursor: pointer;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        transition: all 0.2s ease;

        &:hover {
          border-color: #a598e5;
        }

        &.active {
          border-color: #4c33cc;
          color: #4c33cc;
          background: #f6f5fc;
          font-weight: 500;
        }
      }
    }
  }
}


.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.toolbar {
  display: flex;
  gap: 10px;
}

/* 工具栏按钮组之间的分隔 */
.toolbar .el-button-group+.el-button-group {
  margin-left: 8px;
  padding-left: 8px;
  border-left: 1px solid #dcdfe6;
}

/* 激活状态的按钮样式 */
:deep(.el-button--primary) {
  background-color: var(--el-color-primary-light-3);
  border-color: var(--el-color-primary-light-3);
  color: #fff;
}

/* 预览模式下的布局调整 */
.preview-button {
  z-index: 1000;
}

.preview-mode {
  .header .preview-button {
    display: none;
  }
}

/* 预览按钮样式 */
.preview-button {
  margin-right: 10px;
}

/* 预览模式下的退出按钮样式 */
.preview-exit-button {
  /* position: absolute;
  top: 10px;
  left: 10px;
  z-index: 2000; */
  // box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  background-color: #fff;
  color: var(--el-text-color-primary);

  &:hover {
    background-color: var(--el-color-primary-light-9);
  }
}

/* 响应式布局 */
@media screen and (max-width: 768px) {
  .toolbar {
    flex-wrap: wrap;
  }

  .toolbar .el-button-group {
    margin-bottom: 8px;
  }

  .header {
    flex-direction: column;
    align-items: flex-start;
    gap: 10px;
  }
}
</style>


页面效果

在这里插入图片描述

复制到公众号效果

点击按钮,粘贴到微信公众号编辑器里,可看到html渲染的样式也带到了编辑器中。
在这里插入图片描述

切换样式
在这里插入图片描述

在这里插入图片描述

Markdown编辑器文档

主要功能

  1. Markdown编辑

    • 支持标准Markdown语法
    • 实时预览转换效果
    • 工具栏快捷插入Markdown语法
  2. HTML编辑

    • 支持切换到HTML编辑模式
    • 可以直接编辑生成的HTML
  3. 预览功能

    • 实时预览渲染效果
    • 支持全屏预览模式
    • 多种主题样式切换
  4. 样式主题

    • 提供多套预设主题
    • 支持自定义样式
    • 主题实时切换预览
  5. 导出与复制

    • 导出 Markdown 文件
    • 一键复制到公众号(保留样式)
    • 导出为图片(支持高清PNG格式)

核心依赖包

  1. marked:用于Markdown转HTML的核心库

    npm install marked
    
  2. element-plus:UI组件库

    npm install element-plus
    
  3. html2canvas:用于将DOM内容转换为图片

    npm install html2canvas
    

技术实现

1. Markdown转换

  • 使用marked库进行Markdown到HTML的转换
  • 配置marked选项支持GFM和换行等特性
  • 实时监听内容变化并更新预览
// 配置marked选项
marked.setOptions({
  breaks: true, // 将回车转换为 <br>
  gfm: true, // 启用 GitHub 风格的 Markdown
})

// 使用marked进行Markdown转HTML
const convertMarkdownToHtml = (markdown: string): any => {
  return marked(markdown)
}

2. 编辑器功能

  • 支持工具栏快捷插入Markdown语法
  • 实现撤销/重做功能
  • 支持HTML/Markdown模式切换
// 在光标位置插入Markdown语法
const insertMarkdown = (type: string) => {
  const textarea = document.querySelector('.el-textarea__inner') as HTMLTextAreaElement
  if (!textarea) return

  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  const selected = markdownContent.value.substring(start, end)

  let insertion = ''

  switch (type) {
    case 'bold':
      insertion = `**${selected || '粗体文本'}**`
      break
    // 其他类型...
  }

  // 更新内容和预览
}

3. 样式主题

  • 使用动态样式注入实现主题切换
  • 支持实时预览主题效果
  • 提供预设主题配置
const setStyle = () => {
  // 移除所有旧的样式标签
  const oldStyles = document.querySelectorAll('style[data-md-style]')
  oldStyles.forEach(style => style.remove())

  // 创建新的样式标签
  const styleTag = document.createElement('style')
  styleTag.type = 'text/css'
  styleTag.setAttribute('data-md-style', 'true') // 添加标识,方便后续删除

  // 根据选择的样式设置内容
  styleTag.innerHTML = currentStyle.value === 'style1' ? cssData1 : cssData2

  // 插入到页面头部
  document.head.appendChild(styleTag)
}

4. 导出功能

  • Markdown导出
    • 将编辑器内容导出为.md文件
    • 使用Blob API实现文件下载
const exportFile = () => {
  const blob = new Blob([markdownContent.value], { type: 'text/markdown' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'markdown.md'
  a.click()
  URL.revokeObjectURL(url)
}
  • 复制到公众号
    • 保留样式的富文本复制
    • 支持主流浏览器的剪贴板API
    • 优化微信编辑器的兼容性
    • 自适应宽度设计,适配公众号编辑器
const copyToWechat = () => {
  // 获取预览区域的内容
  const previewContent = document.querySelector('.card-content-inner')
  
  // 创建临时div并应用样式
  const tempDiv = document.createElement('div')
  tempDiv.innerHTML = `
    <section style="width: auto; font-family: -apple-system-font, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', Arial, sans-serif; color: #333; line-height: 1.6; font-size: 16px;">
      ${clonedContent.innerHTML}
    </section>
  `
  
  // 执行复制操作
}
  • 导出图片
    • 使用html2canvas将内容转换为图片
    • 支持高清图片导出(2x缩放)
    • 处理跨域图片资源
    • 优化图片质量和清晰度
const exportAsImage = async () => {
  try {
    // 显示加载提示
    ElMessage({
      message: '正在生成图片,请稍候...',
      type: 'info',
      duration: 0,
      showClose: true
    })

    // 获取要导出的DOM元素
    const element = card.value
    
    // 创建canvas,添加更多配置选项
    const canvas = await html2canvas(element, {
      scale: 2, // 提高图片质量
      useCORS: true, // 允许加载跨域图片
      allowTaint: true, // 允许加载跨域图片(即使可能污染画布)
      backgroundColor: '#ffffff', // 设置背景色
      logging: true, // 开启日志以便调试
      foreignObjectRendering: false, // 禁用foreignObject渲染,提高兼容性
      removeContainer: true, // 移除临时创建的容器
      onclone: (clonedDoc) => {
        // 确保克隆的文档中包含所有必要的样式
        const styles = document.querySelectorAll('style[data-md-style]')
        styles.forEach(style => {
          clonedDoc.head.appendChild(style.cloneNode(true))
        })
      }
    })

    // 转换为图片并下载
    const link = document.createElement('a')
    link.download = 'markdown-card.png'
    link.href = canvas.toDataURL('image/png', 1.0)
    link.click()
    
    ElMessage.success('图片导出成功')
  } catch (error) {
    ElMessage.error(`图片导出失败: ${error.message}`)
  }
}

使用方法

1. 基本编辑

  1. 在左侧编辑区域输入Markdown内容
  2. 右侧实时预览渲染效果
  3. 使用工具栏按钮快速插入Markdown语法

2. 主题切换

  1. 在预览区域顶部选择主题
  2. 实时预览不同主题的效果
  3. 选择最适合的主题样式

3. 导出内容

导出Markdown文件
  1. 点击工具栏的导出按钮
  2. 自动下载为.md文件
复制到公众号
  1. 点击"复制到公众号"按钮
  2. 直接粘贴到公众号编辑器
  3. 样式会自动保留,并且内容宽度会自适应公众号编辑器
导出为图片
  1. 点击"导出为图片"按钮
  2. 等待图片生成(会显示加载提示)
  3. 图片会自动下载为PNG格式
  4. 导出的图片为高清质量(2x缩放)

安装与配置

1. 安装依赖

# 安装核心依赖
npm install marked element-plus html2canvas

# 安装图标库(如果需要)
npm install @element-plus/icons-vue

2. 引入组件

// 在你的Vue组件中引入
import { marked } from 'marked'
import { ElMessage } from 'element-plus'
import html2canvas from 'html2canvas'
import {
  Download, ChatLineSquare, Notebook, Link,
  Picture, List, View, Document
} from '@element-plus/icons-vue'

3. 样式配置

// 引入Element Plus样式
import 'element-plus/dist/index.css'

// 添加自定义样式
// 可以在组件内部使用<style>标签,或者引入外部样式文件

注意事项

  1. 编辑器使用

    • 建议定期保存内容
    • 可以随时切换预览模式
  2. 样式主题

    • 建议在最终导出前确定主题
    • 不同主题可能影响最终效果
  3. 导出注意

    • 复制到公众号时,内容宽度会自适应公众号编辑器
    • 导出图片时,如果内容包含外部图片,确保图片可以正常访问(跨域问题)
    • 导出大型内容为图片时可能需要等待较长时间
    • 图片导出功能依赖于html2canvas库,某些复杂CSS效果可能无法完全还原
  4. 依赖包版本兼容性

    • 确保html2canvas版本与项目其他依赖兼容
    • 如果遇到导出图片问题,可以尝试降级或升级html2canvas版本

开发扩展

1. 添加新主题

  1. 在样式配置中添加新的主题样式
  2. 在主题选择器中添加新选项
  3. 实现主题切换逻辑

2. 自定义工具栏

  1. 在工具栏配置中添加新的工具按钮
  2. 实现相应的功能处理函数
  3. 更新工具栏UI

3. 扩展导出功能

  1. 实现新的导出格式支持
  2. 添加相应的UI控件
  3. 处理导出逻辑

常见问题

  1. 内容不能实时预览

    • 检查marked配置是否正确
    • 确认内容监听是否生效
  2. 样式显示异常

    • 检查主题样式是否正确加载
    • 确认CSS选择器优先级
  3. 导出问题

    • 复制到公众号样式丢失:检查样式处理逻辑
    • 图片导出失败:检查图片资源是否可访问,是否存在跨域问题
    • 导出图片模糊:确认是否启用了高清设置(scale: 2)
    • 导出图片缺少部分内容:检查DOM结构和样式是否完整
  4. html2canvas相关问题

    • 跨域图片无法显示:设置useCORS: trueallowTaint: true
    • 某些CSS效果无法导出:html2canvas对某些高级CSS效果支持有限
    • 导出图片大小问题:调整scale参数和容器大小

更新日志

(记录整个页面功能的开发分块)

v1.0.0

  • 基础编辑器功能
  • Markdown实时预览
  • 基础主题支持

v1.1.0

  • 添加公众号复制功能
  • 优化预览性能
  • 添加多套主题

v1.2.0

  • 添加导出图片功能(使用html2canvas)
  • 优化公众号复制功能,支持自适应宽度
  • 提升样式处理和兼容性
Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐