TinyMCE富文本自定义生成目录(基于vue + typescript)

2,962 阅读2分钟

1、效果描述

在富文本编辑器中将某段落设置成H1-H6任意标题时,实时在左侧【目录导航】中生成目录,取消任意H1-H6标签时,同步更新左侧目录导航。此目录只为做快速定位跳转至富文本标题位置处 (若需要生成类似doc目录树,则需要标准的目录录入限制或者有标识确定嵌套关系) --- 仅供相互学习,讨论

2、实现思路

  • 利用监听dom节点变化的
  • 把我们需要观察的节点(H1-H6)筛选出来
  • 分别给筛选出来的节点(H1-H6)添加上自定义属性,方面定位该元素的位置
  • 把节点列表渲染到左侧目录导航
  • 点击目录导航时回传自定义属性到富文本中,进行滚动定位到节点位置

3、实现步骤流程

(1)vue 中实例化时创建观察器

	
    // 获取iframe的Document 对象
  get iframeDocument() {
    const iframe = (document.querySelector('#tinymce_ifr') as any)
    const iframeDoc = iframe.contentDocument.querySelector('#tinymce')
    return iframeDoc
  }

  mounted() {
    // 创建节点变化观察器
    this.mutationObserverInstall()
    
    // 初始化闭包函数,用于生成唯一的数字作为自定义属性
    this.catalogUniqueNumber = this.generateUniqueNumber()

    // 初始化时,先全文查找h1-h6标签并设置自定义属性
    setTimeout(() => {
      this.findH1ToH6Nodes()
    }, 500)
  }

  
  // 生成唯一的数字
  generateUniqueNumber() {
    var num = 0
    return function() {
      num += 1
      return num
    }
  }

(2)vue组件更新时触发观察器

// 页面更新时
  updated() {
    // 配置观察选项
    const config = {
      // 观察 attributes 改变
      attributes: true,
      // 指定观察属性
      attributeFilter: ['id'],
      // 观察节点增删
      childList: true,
      // 观察文本改变
      characterData: true,
      // 观察子节点树
      subtree: true
    }
    
    // 关闭观察器
    this.observer.disconnect()
    // 观察节点变化
    this.observer.observe(this.iframeDocument, config)
  }

(2.1)监听dom节点变化函数

// 创建观察器
 mutationObserverInstall() {
   const list = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
   let catalogList: any[] = []

   this.observer = new MutationObserver((mutations) => {
     mutations.forEach((mutation: any) => {
       // console.log('mutation', mutation)
       // 过滤出 h1-h6
       const childrens = _.filter(mutation.target.children, item => _.includes(list, item.nodeName))
       // 移除 h1-h6 中的一个
       const removedNodes = _.some(mutation.removedNodes, item => _.includes(list, item.nodeName))

       // childrens.length > 1 ,此时会全量更新h1-h6的节点
       if (!_.isEmpty(mutation.addedNodes) && !_.isEmpty(childrens)) {
         // 先清空
         catalogList = []
         console.log('childrens', childrens)

         // 给元素添加自定义属性
         _.forEach(childrens, child => {
           const foundName = _.find(child.attributes, { name: 'catalogname' })

           // 未添加自定义属性的目录
           if (!foundName) {
             const name = `catalog_${this.catalogUniqueNumber()}`
             child.setAttribute('catalogName', name)

             catalogList.push({
               catalogName: name,
               nodeName: child.nodeName,
               innerHTML: child.innerHTML,
             })
           }
           // 已经添加了自定义属性
           else {
             catalogList.push({
               catalogName: foundName.value,
               nodeName: child.nodeName,
               innerHTML: child.innerHTML,
             })
           }
         })

         this.$emit('showCatalog', catalogList)
       }

       // childrens.length = 0 , 检查到全文中没有h1-h6, 则清空目录列表
       if (removedNodes && _.isEmpty(childrens)) {
         const catalogs = this.iframeDocument.querySelectorAll('h1,h2,h3,h4,h5,h6')

         // 清空目录列表
         if (_.isEmpty(catalogs)) this.$emit('showCatalog', [])
       }
     })
   })
 }

(4)在富文本中找到对应的节点及滚动到对应的位置

// 目录id
  @Watch('activeCatalogName')
  onactiveCatalogNameChange() {
    const titleNode = this.iframeDocument.querySelector(`[catalogname=${this.activeCatalogName}]`)
    console.log('titleNode', titleNode)
    const top = titleNode.offsetTop

    // 设置滚动条滚动到对应的位置
    // iframe的window对象
    ;(document.getElementById('tinymce_ifr') as any).contentWindow.scrollTo(0, top)
  }

(5)查找h1-h6标签并设置自定义属性函数

// 查找所有h1-h6标题节点
  findH1ToH6Nodes() {
    // 获取h1-h6的所有节点
    const childrens = this.iframeDocument.querySelectorAll('h1,h2,h3,h4,h5,h6')

    if (_.isEmpty(childrens)) return

    const catalogList: any[] = []

    // 保留只有id锚点的节点
    _.forEach(childrens, (child: any) => {
      const name = `catalog_${this.catalogUniqueNumber()}`
      child.setAttribute('catalogName', name)

      catalogList.push({
        catalogName: name,
        nodeName: child.nodeName,
        innerHTML: child.innerHTML,
      })
    })

    if (!_.isEmpty(catalogList)) this.$emit('showCatalog', catalogList)
  }