Electron-vite【实战】MD 编辑器 -- 大纲区(含自动生成大纲,大纲缩进,折叠大纲,滚动同步高亮大纲,点击大纲滚动等)

发布于:2025-06-12 ⋅ 阅读:(17) ⋅ 点赞:(0)

最终效果

在这里插入图片描述

页面

    <!-- 大纲 -->
    <div v-if="currentFilePath && outlineList.length" class="outlinePanel">
      <div class="panelTitle">大纲</div>
      <div class="searchTitleBox">
        <Icon class="searchTitleInputIcon" icon="material-symbols-light:search" />
        <input
          v-model="searchTitleKeyWord"
          class="searchTitleInput"
          type="text"
          placeholder="请输入标题"
        />
        <Icon
          v-show="searchTitleKeyWord"
          class="clearSearchTitleInputBtn"
          icon="codex:cross"
          @click="clearSearchTitleInput"
        />
      </div>
      <div class="outlineListBox">
        <template v-for="(item, index) in outlineList_filtered">
          <div
            v-if="!item.hide"
            :key="index"
            :title="item.text"
            class="outlineItem"
            :style="{ color: currentOutLineID === item.id ? 'red' : '' }"
            @click="handle_clickOutLine(item)"
          >
            <div v-for="i in item.level" :key="i">
              <Icon
                v-if="
                  index < outlineList_filtered.length - 1 &&
                  item.level < outlineList_filtered[index + 1].level &&
                  i === item.level
                "
                :icon="
                  item.collapsed ? 'iconamoon:arrow-right-2-light' : 'iconamoon:arrow-down-2-light'
                "
                @click.stop="toggleCollapse(item, index)"
              />
              <Icon v-else icon="" />
            </div>
            <div style="margin-left: 4px">
              {{ item.text }}
            </div>
          </div>
        </template>
      </div>
    </div>

不同级别标题的缩进和折叠图标的实现

            <div v-for="i in item.level" :key="i">
              <Icon
                v-if="
                  index < outlineList_filtered.length - 1 &&
                  item.level < outlineList_filtered[index + 1].level &&
                  i === item.level
                "
                :icon="
                  item.collapsed ? 'iconamoon:arrow-right-2-light' : 'iconamoon:arrow-down-2-light'
                "
                @click.stop="toggleCollapse(item, index)"
              />
              <Icon v-else icon="" />
            </div>
  • 按 level 值,渲染空白图标, level 值越大,缩进越多。
  • 仅当下一行标题的 level 更大,即有子标题时,显示大纲折叠图标

相关样式

/* 大纲的样式 */
.outlinePanel {
  width: 200px;
  border: 1px solid gray;
  border-left: none;
  display: flex;
  flex-direction: column;
  font-size: 14px;
}
.searchTitleBox {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 10px;
}
.searchTitleInput {
  display: block;
  font-size: 12px;
  padding: 4px 20px;
}
.searchTitleInputIcon {
  position: absolute;
  font-size: 16px;
  transform: translateX(-80px);
}
.clearSearchTitleInputBtn {
  position: absolute;
  cursor: pointer;
  font-size: 16px;
  transform: translateX(77px);
}
.outlineListBox {
  padding: 10px;
  line-height: 1.5;
  flex: 1;
  overflow-y: auto;
}
.outlineItem {
  cursor: pointer;
  text-wrap: nowrap;
  display: flex;
  align-items: center;
  color: #787474;
  margin-bottom: 6px;
}

相关变量

// 大纲相关变量
const searchTitleKeyWord = ref('') // 搜索大纲的关键词
const currentOutLineID = ref('0') // 当前选中的大纲项的id
const ifClickOutLine = ref(false) // 是否点击了大纲项
// 计算属性:根据markdownContent生成大纲列表
const outlineList = computed(() => {
  const originalList = MarkdownParser(markdownContent.value).outline || []
  // 使用 reactive 让大纲项的每个属性都是响应式
  return originalList.map((item) => reactive({ ...item, collapsed: false }))
})
// 计算属性:根据searchTitleKeyWord过滤大纲列表
const outlineList_filtered = computed(() => {
  let result = outlineList.value.filter((outline) => {
    return outline.text.toLowerCase().includes(searchTitleKeyWord.value.toLowerCase())
  })
  return result
})

核心方法

根据 markdown 内容生成大纲

const originalList = MarkdownParser(markdownContent.value).outline || []

在 MarkdownParser 方法中,遍历每一行时,将标题行转换为目标格式,存入 outlineList 中

    // 标题
    if (line.startsWith('#')) {
      const resultTemp = parseMarkdownHeadings(line, index)
      if (resultTemp) {
        line = resultTemp.content
        outlineList.push({
          ...resultTemp.outline,
          index
        })
      }
    }

最终返回

  return {
    content: result,
    outline: outlineList
  }

其中格式解析的方法 parseMarkdownHeadings 如下:

/**
 * 将 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
      }
    }
  }
}

切换大纲项的折叠状态

// 切换大纲项的折叠状态
const toggleCollapse = (item: outlineItemType, index: number): void => {
  item.collapsed = !item.collapsed
  for (let i = index + 1, len = outlineList_filtered.value.length; i < len; i++) {
    const outline = outlineList_filtered.value[i]
    if (outline.level > item.level) {
      outline.hide = item.collapsed
    } else {
      break
    }
  }
}

点击大纲项滚动编辑区和预览区

在这里插入图片描述

 @click="handle_clickOutLine(item)"
// 点击大纲项
const handle_clickOutLine = (item): void => {
  currentOutLineID.value = item.id
  const editor = editorRef.value
  if (editor) {
    ifClickOutLine.value = true
    // 编辑区滚动滚动到指定行
    const lineHeight = parseInt(getComputedStyle(editor).lineHeight)
    const paddingTop = parseInt(getComputedStyle(editor).paddingTop)
    editor.scrollTo({
      top: paddingTop + item.index * lineHeight,
      behavior: 'smooth'
    })
  }
  const preview = previewRef.value
  if (preview) {
    const targetDom = document.getElementById(item.id)
    if (targetDom) {
      targetDom.scrollIntoView({
        behavior: 'smooth',
        block: 'start'
      })
      setTimeout(() => {
        // 延迟1s后,将 ifClickOutLine 置为 false,防止点击大纲项时,在滚动编辑区时,同步滚动预览区
        ifClickOutLine.value = false
      }, 1000)
    }
  }
}

点击大纲项时,禁止编辑区的滚动导致预览区同步滚动

// 同步预览区滚动
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
  }
}

滚动预览区时,当前高亮大纲同步变化

onMounted(() => {
  if (previewRef.value) {
    // 监听滚动事件以更新当前大纲项ID
    previewRef.value.addEventListener('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
}

onBeforeUnmount 中

  if (previewRef.value) {
    previewRef.value.removeEventListener('click', handle_preview_click)
    previewRef.value.removeEventListener('scroll', update_currentOutlineId)
  }