基于tiptap编辑的富文本内容展示

主要涉及一些常用的功能及兼容(部分展示与富文本编辑时有出入)

  1. 代码高亮
  2. 表格宽度自适应
  3. 表头固定
  4. 目录
  5. 富文本内容划词评论

需要安装图片展示的插件:"v-viewer":"^3.0.10",demo如下:

<script lang="ts" setup>
// 插件里使用了element-plus的复选框组件
import 'element-plus/es/components/checkbox/style/css.mjs'
import { toRefs } from 'vue'
import { api as viewImage } from 'v-viewer'
import { useTableEnhancer } from './use-table-enhancer'
import { useTableColumnWidth } from './use-table-column-width'
import { useHighlightSyntax } from '@/components/YEditorRenderer/use-highlight-syntax'
import { useDirectory } from '@/components/YEditorRenderer/use-directory'

const props = withDefaults(
  defineProps<{
    /**
     * @deprecated
     */
    id?: string
    content: string
    enableFixedHeader?: boolean
    /**
     * 是否需要目录。默认值:`false`
     */
    enableDirectory?: boolean
    rendererClass?: string
  }>(),
  {
    id: 'demand-editor',
    enableFixedHeader: true,
    enableDirectory: false,
    rendererClass: '',
  },
)

const { content, id, enableFixedHeader, enableDirectory, rendererClass } = toRefs(props)

const emits = defineEmits(['update:editorContent', 'onClick'])

const uniqueId = computed(() => 'editor-render-' + Date.now() + Math.random())
const editorSelector = computed(() => `[data-id="${uniqueId.value}"]`)

const handleEditorClick = (e: any) => {
  if (e.target.tagName === 'IMG') {
    viewImage({ images: [e.target.src] })
    return
  }
  emits('onClick', e)
}

useHighlightSyntax(editorSelector.value + ' pre', content)
useTableColumnWidth(content, editorSelector.value)
useTableEnhancer(editorSelector.value, { enableFixedHeader })
useDirectory(editorSelector.value, content, { enableDirectory })
</script>

<template>
  <div class="wrapper">
    <div
      :id="id"
      :data-id="uniqueId"
      class="custom-editor el-tiptap-editor__content render-editor-content"
      :class="rendererClass"
      @click="handleEditorClick"
      v-html="content"
    ></div>
    <slot name="mark" :selector="editorSelector" :content="content"></slot>
  </div>
</template>

<style lang="scss" scoped>
.wrapper {
  position: relative;
  display: flex;
  flex-direction: row;
  width: 100%;
  margin-top: 8px;
  // 请勿开启overflow-y=auto,否则无法动态寻找滚动父节点
  overflow: hidden;
}

:deep(pre) {
  code:not(.highlight-code) {
    color: transparent !important;

    &::selection {
      color: transparent !important;
    }
  }

  // pre下只有一个code标签则说明是因为某种原因导致高亮逻辑未被触发
  code:only-child {
    color: #fff !important;
    padding: 10px !important;
  }

  > .code-language {
    color: #aaa;
    position: absolute;
    right: 0px;
    top: 0px;
    font-size: 12px;
    background-color: #aaa;
    color: #0d0d0d;
    display: inline-block;
    padding: 0px 5px;
    line-height: 14px;
    border-radius: 6px;
    pointer-events: none;
  }

  > code.highlight-code {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    pointer-events: none;
    color: #fff;
  }
}

.render-editor-content {
  flex: 1;
  min-height: 50px;
  height: 100%;
  // 请勿开启overflow-y=auto,否则无法动态寻找滚动父节点
  overflow: hidden;
  :deep(.custom-editor-directory) {
    a:hover {
      text-decoration: underline;
    }
  }
}
</style>

其中的表头固定、自适应表格宽度、高亮代码、生成目录分别封装成hooks

工具函数 @utils/common.ts

需要准备插件 "highlight.js":"^11.9.0"

export const EditorDocumentDirectory = 'data-editor-document-directory'

export const getScrollElement = (child: HTMLElement | null): HTMLElement | null => {
  let $scrollElement: HTMLElement | null | undefined = child
  do {
    if (!$scrollElement) break
    const overflowY = getComputedStyle($scrollElement).overflowY
    if (overflowY === 'auto' || overflowY === 'scroll') {
      break
    }
  } while (($scrollElement = $scrollElement.parentElement))

  return $scrollElement || null
}

export const injectCodeLineNumbers = ($lineNumbers: Element, lineCount: number) => {
  let spans: string = ''
  for (let i = 1; i <= lineCount; ++i) {
    spans += `<span>${i}</span>`
  }
  $lineNumbers.innerHTML = spans
  $lineNumbers.classList.add('code-line-numbers')
  $lineNumbers.classList.remove('size3x', 'size4x', 'size5x')
  if (lineCount > 9999) $lineNumbers.classList.add('size5x')
  else if (lineCount > 999) $lineNumbers.classList.add('size4x')
  else if (lineCount > 99) $lineNumbers.classList.add('size3x')
}

import 'highlight.js/styles/androidstudio.css'
import hljs from 'highlight.js'
import c from 'highlight.js/lib/languages/c'
import cpp from 'highlight.js/lib/languages/cpp'
import python from 'highlight.js/lib/languages/python'
import shell from 'highlight.js/lib/languages/shell'
import typescript from 'highlight.js/lib/languages/typescript'
import java from 'highlight.js/lib/languages/java'
import kotlin from 'highlight.js/lib/languages/kotlin'
import json from 'highlight.js/lib/languages/json'
import php from 'highlight.js/lib/languages/php'
import go from 'highlight.js/lib/languages/go'
import rust from 'highlight.js/lib/languages/rust'
import sql from 'highlight.js/lib/languages/sql'
import diff from 'highlight.js/lib/languages/diff'
import yaml from 'highlight.js/lib/languages/yaml'
import xml from 'highlight.js/lib/languages/xml'
import text from 'highlight.js/lib/languages/plaintext'
import nginx from 'highlight.js/lib/languages/nginx'
import dockerfile from 'highlight.js/lib/languages/dockerfile'
import lua from 'highlight.js/lib/languages/lua'
import css from 'highlight.js/lib/languages/css'

export const highlightLanguages = {
  c,
  cpp,
  java,
  kotlin,
  go,
  rust,
  python,
  php,
  lua,
  javascript: typescript,
  css,
  json,
  xml,
  yaml,
  nginx,
  dockerfile,
  shell,
  diff,
  sql,
  text,
}

export const highlight = hljs

Object.entries(highlightLanguages).forEach(([language, fn]) => {
  hljs.registerLanguage(language, fn)
})
表头固定:use-table-enhancer.ts
import { Ref } from 'vue'
import { getScrollElement } from '@/utils/common'

export function useTableEnhancer(selector: string, options: { enableFixedHeader: Ref<boolean> }) {
  onMounted(() => {
    const $scrollElement = getScrollElement(document.querySelector<HTMLElement>(selector))
    $scrollElement && registerEvents($scrollElement)
  })

  const registerEvents = ($scrollElement: HTMLElement) => {
    // console.log('Register table listeners', $scrollElement)
    const $renderer = document.querySelector<HTMLElement>(selector)!
    const listenScrollbar = createScrollbarListener($renderer, $scrollElement)
    const listenFixedHeader = createFixedHeaderListener(
      $renderer,
      $scrollElement,
      options.enableFixedHeader,
    )

    // table的横向滚动条也会被监听到
    $scrollElement.addEventListener('scroll', listenScrollbar, true)
    $scrollElement.addEventListener('scroll', listenFixedHeader, true)

    const observer = new ResizeObserver(() => {
      listenScrollbar(undefined, true)
      listenFixedHeader(undefined, true)
    })
    observer.observe($scrollElement)
    // 划词会挤压内容
    observer.observe($renderer)

    watch(
      () => options.enableFixedHeader.value,
      () => {
        $scrollElement.dispatchEvent(new Event('scroll'))
      },
    )

    // Vue在update dom的时候可能会用到节点作为参考节点(insert Before),在onMounted的时候删除则没问题
    let effectNodes: HTMLElement[] = []

    onBeforeUnmount(() => {
      // console.log('unmount table listeners')
      observer.disconnect()
      $scrollElement.removeEventListener('scroll', listenFixedHeader, true)
      $scrollElement.removeEventListener('scroll', listenScrollbar, true)
      const rendererDataId = document.querySelector<HTMLElement>(selector)?.dataset.id
      if (rendererDataId) {
        effectNodes = (Array.from($scrollElement.children) as HTMLElement[]).filter(
          ($item) =>
            $item.dataset.rendererId === rendererDataId &&
            ($item.classList?.contains('table-fixed-header-global') ||
              $item.classList?.contains('table-scrollbar-global')),
        )
      } else {
        effectNodes = []
      }
    })

    onUnmounted(() => {
      effectNodes.forEach(($node) => $node.remove())
    })
  }
}

const createScrollbarListener = ($renderer: HTMLElement, $scrollElement: HTMLElement) => {
  let $scrollingWrapper: HTMLElement | null = null

  const rendererDataId = $renderer.dataset.id
  const $virtualScrollbar = document.createElement('div')
  $virtualScrollbar.className = 'table-scrollbar-global'
  $virtualScrollbar.dataset.rendererId = rendererDataId
  $virtualScrollbar.appendChild(document.createElement('div'))
  $scrollElement.appendChild($virtualScrollbar)
  $renderer
    .getAttributeNames()
    .filter((attr) => attr.indexOf('data-v') === 0)
    .forEach((attr) => {
      $virtualScrollbar.setAttribute(attr, '')
    })

  $virtualScrollbar.addEventListener(
    'scroll',
    () => {
      $scrollingWrapper && ($scrollingWrapper.scrollLeft = $virtualScrollbar!.scrollLeft)
    },
    true,
  )

  return (e?: Event, resizing: boolean = false) => {
    if (
      e?.type === 'scroll' &&
      (e.target === $virtualScrollbar || (e.target as Element).classList.contains('tableWrapper'))
    ) {
      // 各个表格的横向滚动条也会触发该事件
      return
    }

    const tables = $renderer.querySelectorAll<HTMLTableElement>(
      // 防止子table
      `.tableWrapper > table`,
    )
    const scrollElementRect = $scrollElement.getBoundingClientRect()

    let selectedIndex = -1
    for (let index = 0; index < tables.length; ++index) {
      const table = tables[index]!
      const $tableWrapper = table.parentElement!
      const tableRect = table.getBoundingClientRect()

      // 表头在容器可见区域下方,后面的表格也无需再看了
      if (tableRect.top > scrollElementRect.bottom) break
      // 整个表格都在容器可见区域上方
      if (tableRect.bottom < scrollElementRect.top) continue
      // 表格滚动条已经出现在容器可见区域
      if (tableRect.bottom < scrollElementRect.bottom) continue

      const tableWrapperRect = $tableWrapper.getBoundingClientRect()
      // 虚拟滚动条绑定当前表格
      // 表格宽度小于容器,无需出现滚动条
      if (tableRect.width <= tableWrapperRect.width) break

      selectedIndex = index
      const recreating = $scrollingWrapper !== $tableWrapper
      $scrollingWrapper = $tableWrapper

      $virtualScrollbar.scrollLeft = $tableWrapper.scrollLeft

      if (recreating || resizing || Math.random() > 0.85) {
        // 移除隐藏样式,才能获得真实位置
        $virtualScrollbar.classList.remove('hidden')
        $virtualScrollbar.style.width = `${tableWrapperRect.width}px`
        $virtualScrollbar.style.left = `${tableWrapperRect.left}px`
        $virtualScrollbar.style.top = scrollElementRect.bottom - 30 + 'px'
        ;($virtualScrollbar.firstChild as HTMLDivElement).style.width = tableRect.width + 'px'
        fixPosition($virtualScrollbar)
      }

      break
    }

    if (selectedIndex === -1) {
      $scrollingWrapper = null
      $virtualScrollbar.classList.add('hidden')
    }
  }
}

/**
 * 手动触发
   element.dispatchEvent(
     new CustomEvent('scroll', {
       detail: { updateFixedHeader: true }
     })
   )
 */
const createFixedHeaderListener = (
  $renderer: HTMLElement,
  $scrollElement: HTMLElement,
  enableFixedHeader: Ref<boolean>,
) => {
  return (e?: Event, resizing?: boolean) => {
    const rendererDataId = $renderer.dataset.id
    let $globalFixedHeader =
      (Array.from($scrollElement.children) as HTMLElement[]).find(
        ($item) =>
          $item.classList?.contains('table-fixed-header-global') &&
          $item.dataset.rendererId === rendererDataId,
      ) || null

    if (!enableFixedHeader.value) {
      $globalFixedHeader?.remove()
      return
    }

    if (e instanceof CustomEvent && e.detail.updateFixedHeader) {
      $globalFixedHeader?.remove()
      $globalFixedHeader = null
    }

    const tables = $renderer.querySelectorAll<HTMLTableElement>(
      // 防止子table
      `.tableWrapper > table`,
    )

    const scrollElementOffsetTop = $scrollElement.getBoundingClientRect().top
    let hasFixedHeader = false

    tableLoop: for (let index = 0; index < tables.length; ++index) {
      const table = tables[index]!
      const $tableWrapper = table.parentElement!

      const firstTr = table.querySelector('tr')
      if (!firstTr) continue

      const firstThs = firstTr.children
      // 横向表头,纵向表头(不固定),没有表头(不固定)
      for (let i = 0; i < firstThs.length; ++i) {
        if (firstThs[i].tagName !== 'TH') {
          continue tableLoop
        }
      }

      const trs: HTMLTableRowElement[] = [firstTr]
      {
        let rowSpan = 1
        for (let i = 0; i < firstThs.length; ++i) {
          rowSpan = Math.max(Number((firstThs.item(i) as HTMLTableCellElement).rowSpan), rowSpan)
        }
        for (let i = 1; i < rowSpan; ++i) {
          const tr = firstTr.parentElement!.children.item(i) as HTMLTableRowElement
          tr && trs.push(tr)
        }
      }

      const tableRect = table.getBoundingClientRect()
      const headerHeight = trs.reduce((summary, $tr) => summary + $tr.offsetHeight, 0)

      // 还没到顶
      if (tableRect.top > scrollElementOffsetTop) break
      // 只有表头
      if (headerHeight === tableRect.height) continue
      const hidingHeight = scrollElementOffsetTop - tableRect.top
      const showingHeight = tableRect.height - hidingHeight
      // 已经过顶
      if (showingHeight <= 0) continue

      // 固定表头绑定当前表格
      // 即将过顶,固定表格和表格尾部开始重叠
      if (showingHeight < headerHeight) break

      let $clonedTable!: HTMLTableElement
      let clonedTrs: HTMLTableRowElement[] = []
      let recreating = false

      if ($globalFixedHeader) {
        $clonedTable = $globalFixedHeader.firstChild as HTMLTableElement
        clonedTrs = Array.from(
          $globalFixedHeader.querySelector('tr')!.parentElement!.children,
        ) as HTMLTableRowElement[]

        if (
          clonedTrs.length !== trs.length ||
          clonedTrs.some(($clonedTr, i) => {
            return $clonedTr.children.length !== trs[i].children.length
          })
        ) {
          clonedTrs = []
          $globalFixedHeader?.remove()
          $globalFixedHeader = null
        }
      }

      if (!$globalFixedHeader) {
        recreating = true
        $globalFixedHeader = document.createElement('div')
        $renderer
          .getAttributeNames()
          .filter((item) => item === 'class' || item === 'style' || item.indexOf('data-v') === 0)
          .forEach((attr) => {
            $globalFixedHeader!.setAttribute(attr, $renderer.getAttribute(attr)!)
          })
        $globalFixedHeader.classList.add('table-fixed-header-global')
        $globalFixedHeader.dataset.rendererId = rendererDataId
        $clonedTable = table.cloneNode(false) as HTMLTableElement
        {
          // 已知:table-layout=fixed 与 width 一起使用时,列宽只根据col或者第一行的列宽渲染。
          // 场景:在表头超过一行的情况下,如果第一行列宽有合并列,则导致后面行的列无法按照指定宽度渲染。
          // 方案:设置minWidth为真实表格宽度(原minWidth是后期设置的,不准确),删除width。
          $clonedTable.style.minWidth = tableRect.width + 'px'
          $clonedTable.style.width = ''
        }

        clonedTrs = trs.map(($tr) => {
          const $clonedTr = $tr.cloneNode(true) as HTMLTableRowElement
          $clonedTable.appendChild($clonedTr)
          return $clonedTr
        })
        $globalFixedHeader.appendChild($clonedTable)
        // 不能设置在renderer里面,因为划词评审的时候会把所有内容传到服务器
        $scrollElement.appendChild($globalFixedHeader)
      }

      const tableWrapperScrollLeft = $tableWrapper.scrollLeft
      $globalFixedHeader.style.top = scrollElementOffsetTop + 'px'
      $clonedTable.style.marginLeft = -tableWrapperScrollLeft + 'px'

      if (recreating || resizing || Math.random() > 0.75) {
        $globalFixedHeader.style.width = Math.min($tableWrapper.offsetWidth, tableRect.width) + 'px'
        $globalFixedHeader.style.left = tableRect.left + tableWrapperScrollLeft + 'px'

        clonedTrs.forEach(($clonedTr, index) => {
          const ths = trs[index]!.children
          const clonedThs = $clonedTr.children
          for (let i = 0; i < ths.length; ++i) {
            const $clonedTh = clonedThs.item(i) as HTMLTableCellElement
            // 不能使用offsetWidth,因为它拿到的是整数,小数点去掉有可能导致文字换行
            const width = (ths.item(i) as HTMLTableCellElement).getBoundingClientRect().width
            if (width === 0) {
              $clonedTh.style.display = 'none'
            } else {
              $clonedTh.style.display = ''
              $clonedTh.style.width = width + 'px'
            }
          }
        })

        fixPosition($globalFixedHeader)
      }

      hasFixedHeader = true
      break
    }

    if ($globalFixedHeader && !hasFixedHeader) {
      $globalFixedHeader.remove()
    }
  }
}

// 在绝对定位(比如弹窗、抽屉)中,fixed的位置是相对于带 position=absolute|fixed 的祖先元素的
const fixPosition = ($node: HTMLElement) => {
  const fixedHeaderRect = $node.getBoundingClientRect()

  if (
    fixedHeaderRect.left - $node.offsetLeft > 2 /* 误差 */ ||
    fixedHeaderRect.top - $node.offsetTop > 2 /* 误差 */
  ) {
    const offset = getRelativeOffset($node.parentElement)
    $node.style.left = fixedHeaderRect.left + offset.left + 'px'
    $node.style.top = fixedHeaderRect.top + offset.top + 'px'
  }
}

const getRelativeOffset = ($start: Element | null) => {
  const offset = { top: 0, left: 0 }
  let $current: Element | null = $start
  while ($current) {
    const style = window.getComputedStyle($current)
    if (style.position === 'absolute' || style.position === 'fixed') {
      const rect = $current.getBoundingClientRect()
      offset.top -= rect.top
      offset.left -= rect.left
    }
    $current = $current.parentElement
  }
  return offset
}
自适应表格宽度:use-table-column-width.ts
import type { Ref } from 'vue'

export const useTableColumnWidth = (content: Ref<string>, selector: string) => {
  let width = 0

  const observer = new ResizeObserver(([entry]) => {
    const nextWidth = entry.target.clientWidth
    if (width !== nextWidth) {
      width = nextWidth
      setWidth(selector)
    }
  })
  onMounted(() => {
    observer.observe(document.querySelector(selector)!)
  })
  onUnmounted(() => {
    observer.disconnect()
  })
  watch(
    () => content.value,
    () => {
      nextTick(() => {
        setWidth(selector)
      })
    },
  )
}

const isTableInTable = ($table: HTMLElement) => {
  let $parent = $table.parentNode
  do {
    if (!$parent) return false
    if ($parent.nodeName === 'TR') return true
  } while (($parent = $parent.parentNode))
}

const splitter = /,\s?/

const setWidth = (selector: string) => {
  // console.info('Setting col width by selector: ' + selector)
  const editor = document.querySelector(selector)
  const tables = Array.from(editor?.querySelectorAll('table') || [])

  if (tables.length === 0) return tables

  tables.forEach((table, index) => {
    const enableWrapper = !isTableInTable(table)
    // table的第一个tr,可能是表头th,也可能没有表头直接就是td
    const firstTableRow = table.querySelector('tr')
    const thsOrTds = Array.from(firstTableRow?.children || [])
    const colWidthList: number[] = []
    thsOrTds.forEach((thOrTd) => {
      const colwidth = thOrTd.getAttribute('colwidth') || ''
      const colspan = Number(thOrTd.getAttribute('colspan') || 1)
      // "12,65" "12, 65"  "12,,65"   "12,, 65"  "12"
      const colwidths = colwidth.split(splitter).map(Number)
      for (let i = 0; i < colspan; ++i) {
        colWidthList.push(colwidths[i] || 0)
      }
    })

    const colGroup = document.createElement('colgroup')
    colGroup.innerHTML = colWidthList
      .map((colWidth) => {
        return colWidth !== 0 ? `<col style="width: ${colWidth}px" />` : `<col />`
      })
      .join('')

    // querySelector匹配是使用深度优先先序遍历,从文档标记中的第一个元素开始,并按子节点的顺序依次遍历。
    // table里可能有子table,querySelector有可能匹配到子table的colgroup
    const historyColGroup = Array.from(table.children).find(
      ($child) => $child.nodeName === 'COLGROUP',
    )

    if (historyColGroup?.innerHTML !== colGroup.innerHTML) {
      historyColGroup?.remove()
      table.appendChild(colGroup)
    }

    // 设置表格宽度,防止文字重叠在一起
    {
      const tableWidth = colWidthList.reduce((sum, colWidth) => sum + colWidth, 0)
      table.style.minWidth = tableWidth + 'px'
      if (colWidthList.every((colWidth) => colWidth !== 0)) {
        // 一些元素会莫名其妙地撑大单元格(已知:代码块),使得其他单元格被挤压。设置width可解决
        table.style.width = tableWidth + 'px'
      }
    }

    const $tableParent = table.parentElement
    if (!enableWrapper) {
      if ($tableParent?.classList.contains('tableWrapper')) {
        $tableParent.replaceWith(table)
      }
    } else {
      // 给table新加一层div
      if (!$tableParent?.classList.contains('tableWrapper')) {
        const $wrapper = document.createElement('div')
        $wrapper.className = 'tableWrapper'
        $wrapper.id = 'tableWrapper' + index
        $tableParent?.replaceChild($wrapper, table)
        $wrapper.appendChild(table)
      } else {
        $tableParent.id = 'tableWrapper' + index
      }
    }
  })

  return tables
}
高亮代码:use-highlight-syntax.ts
import { Ref } from 'vue'
import { injectCodeLineNumbers, highlight } from '@/utils/common'

/**
 * 代码高亮和行号逻辑
 * @param selector 节点内所有的 code 都会被高亮
 * @param dependency 变更依赖,变更后重新设置高亮
 */
export const useHighlightSyntax = (selector: string, dependency: Ref<string>) => {
  onMounted(() => {
    watch(
      () => dependency.value,
      () => {
        document.querySelectorAll<HTMLElement>(selector).forEach(($pre) => {
          const $code = $pre.querySelector('code')
          if (!$code) return
          const lines = $code.innerHTML.split('\n')
          lines[lines.length - 1] === '' && lines.pop()
          const lineCount = lines.length

          {
            const $cloneCode = $code.cloneNode(true) as HTMLElement
            $cloneCode.classList.add('highlight-code')
            // 代码太长导致高亮卡顿
            if (lineCount < 500) {
              // 如果没有找到对应的language,
              // 则不会生成 class=.hljs,也不会增加属性data-highlighted="true"
              highlight.highlightElement($cloneCode)
            }
            $pre.appendChild($cloneCode)
            $code.addEventListener('scroll', () => {
              $cloneCode.scrollLeft = $code.scrollLeft
            })
          }

          {
            const matchedLanguage = ($code.getAttribute('class') || '').match(/language-([\S]+)/)
            if (matchedLanguage) {
              const $codeLanguage = document.createElement('span')
              $codeLanguage.classList.add('code-language')
              $codeLanguage.textContent = matchedLanguage[1]
              $pre.appendChild($codeLanguage)
            }
          }

          {
            const $lineNumbers = document.createElement('div')
            injectCodeLineNumbers($lineNumbers, lineCount)
            $pre.prepend($lineNumbers)
          }
        })
      },
      { flush: 'post', immediate: true },
    )
  })
}
生成目录:use-directory.ts
import { EditorDocumentDirectory } from '@/utils/common'
import { Ref } from 'vue'
/**
 * 支持生成目录
 * @param selector 渲染容器
 * @param dependency 变更依赖
 * @param options 开关控制
 */
export function useDirectory(
  selector: string,
  dependency: Ref<string>,
  options: { enableDirectory: Ref<boolean> },
) {
  onMounted(() => {
    watch(
      () => [dependency.value, options.enableDirectory.value],
      async () => {
        if (!dependency.value) return
        await nextTick()
        handleGenerateDirectory(options.enableDirectory.value, selector)
        handleHash()
      },
      { immediate: true },
    )
  })
}

export const handleGenerateDirectory = (isStick: boolean, selector: string) => {
  try {
    const dom = document.querySelector(selector)!
    if (!isStick) {
      //删除
      dom.querySelector<HTMLElement>(`ul[${EditorDocumentDirectory}]`)?.remove()
      dom.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6').forEach(($hNode) => {
        $hNode.removeAttribute('id')
      })
      return
    }
    //添加
    const headNodes = dom.querySelectorAll('h1,h2,h3,h4,h5,h6')
    if (headNodes.length <= 0) return //没有标题就不必再执行了
    const directory = document.createElement('ul')
    directory.style.userSelect = 'none' //禁止划词
    directory.style.paddingBottom = '20px'
    directory.setAttribute(EditorDocumentDirectory, '1')
    directory.classList.add('custom-editor-directory') //自定义一个类名
    let map = {}
    for (let item of Array.from(headNodes)) {
      const textContent = item.textContent
      if (!textContent) continue //标题为空不要生成目录
      const uniqueId = item.tagName + '-' + textContent
      //解决出现重复标题
      let count = map[uniqueId] || 0
      item.id = count > 0 ? `${uniqueId}-${count}` : uniqueId
      map[uniqueId] = ++count
      const level = parseInt(item.tagName.charAt(1)) // 获取标题级别,如h1的级别为1
      const li = document.createElement('li')
      const link = document.createElement('a')
      link.textContent = textContent
      link.href = '#' + item.id // 使用标题的id作为锚点链接
      // 根据标题级别设置缩进样式
      li.style.marginLeft = (level - 1) * 20 + 'px'
      // 将链接添加到目录项
      li.appendChild(link)
      directory.appendChild(li)
    }
    //将目录插入文档顶部
    dom.insertAdjacentHTML('afterbegin', directory.outerHTML)
  } catch (error) {
    console.error(error, '动态生成目录报错了')
  }
}

export const handleHash = () => {
  const hash = decodeURIComponent(window.location.hash)
  if (hash) {
    const element = document.getElementById(hash.slice(1))
    if (element) {
      element.scrollIntoView({ behavior: 'auto' })
    }
  }
}

划词内容与富文本渲染没有直接耦合,所以不再此封装上说明

详情见mark.md


愚者
12 声望3 粉丝