WangEditor简介

WangEditor 是一款流行的富文本编辑器,它提供了许多功能和特性,使得在网页中实现富文本编辑变得更加简单和便捷。以下是一些关于 WangEditor 的评价:

  1. 易于集成:WangEditor 提供了简单的 API 和文档,方便开发者快速集成到他们的项目中。

  2. 功能丰富:WangEditor 提供了丰富的编辑功能,包括文字样式设置、插入图片、插入视频、表格编辑等,满足了大部分富文本编辑的需求。

  3. 定制化:WangEditor 支持自定义配置和样式,开发者可以根据自己的需求定制编辑器的外观和功能。

  4. 支持多平台:WangEditor 支持在 PC 和移动端进行使用,具有良好的跨平台兼容性。

  5. 持续更新:WangEditor 团队持续更新和维护编辑器,修复 bug 并增加新功能,保持了编辑器的稳定性和可靠性。

SSML简介

SSML(Speech Synthesis Markup Language)是一种用于控制文本到语音(TTS)转换过程的标记语言。它允许开发者控制文本的发音、语速、语调、音量等方面,以提高生成的语音的自然度和表现力。以下是关于 SSML 的一些简要介绍:

  1. 控制音频输出:SSML 允许开发者控制文本转语音的各个方面,如语速、音调、音量、语气等,以使合成语音更加自然和生动。

  2. 语音合成引擎支持:许多语音合成引擎(如 Amazon Polly、Google Text-to-Speech 等)支持 SSML,开发者可以使用 SSML 标记来增强生成的语音。

  3. 标记语言:SSML 是一种标记语言,类似于 HTML,通过在文本中插入标记来控制语音合成引擎的行为。

  4. 功能丰富:SSML 提供了丰富的标记和选项,可以控制音频输出的各个方面,如音调、语速、静音、发音等。

  5. 应用领域:SSML 主要用于语音合成应用中,如语音助手、语音导航、语音广播等领域,可以提高生成语音的质量和表现力。

总的来说,SSML 是一种用于控制文本到语音转换过程的标记语言,通过使用 SSML,开发者可以更精细地控制生成的语音,使之更加自然、生动和符合特定的语境需求。

需求简介

我们需要开发一款SSML富文本编辑器,来控制TTS转换的效果,SSML和HTML极其相似,我们引入普通HTML富文本编辑器的思路,来开发一款SSML编辑器。

为什么选择WangEditor

WangEditor有一个很重要的特性:自定义元素(官方文档),编辑器默认只有基本的标题、列表、文字、图片、表格等元素,如果你想让编辑器渲染一个新元素,如 附件 数学公式 链接卡片 等,就可以通过这个功能自定义实现。

有哪些坑?

  1. 文档示例过少

  2. 开发过程略微复杂,细节很多

  3. 公开的实践案例较少,无法提供很好的参照

  4. 报错信息不明确,很多异常根本无法得到有效的提示

参考资料

Github大神基于WangEditor实现的SSML编辑器:https://github.com/mekumiao/ssml-editor?tab=readme-ov-file

实现过程

技术栈

Vue3+、vite

技术路线

根据实际的需求和技术实现难度,实现路径不完全与官网的例子相同,并不完全实现编辑器对新标签啊的支持,仅仅实现编辑器对新标签的显示,设置和获取通过单独的函数处理。

具体代码逻辑(以定义注音phoneme功能为例)

1、将phoneme标签定义为inline和void,且禁用掉换行和tab操作,简化编辑器操作逻辑

function registerPlugin(editor) {                       
  const { isInline, isVoid } = editor
  const newEditor = editor

  newEditor.isInline = (elem) => {
    const type = DomEditor.getNodeType(elem)
    if (type === 'phoneme') return true
    return isInline(elem)
  }

  newEditor.isVoid = (elem) => {
    const type = DomEditor.getNodeType(elem)
    if (type === 'phoneme') return true
    return isVoid(elem)
  }

  // 重写 insertBreak 换行  禁止换行
  newEditor.insertBreak = () => {
    return
  }
  // 重写 tab  禁止tab
  newEditor.handleTab = () => {
    return
  }
  return newEditor // 返回 newEditor ,重要!!!
}
// 把插件 registerPlugin 注册到 wangEditor
Boot.registerPlugin(registerPlugin)

2、渲染函数逻辑,在编辑器中渲染出节点。技术要点如下:

  1. 自定义class类名的配置在props中的className字段,后期使用此class定义css

  2. 删除功能,要拿到元素的path,通过path删除,然后将节点的文本以普通文本的方式插入进编辑器

  3. 注册元素的type很重要,要与插入时的保持一致

  4. 元素css必须添加左右padding,否则光标无法选中元素

  // 在编辑器中渲染新元素
  const phonemeRenderAttachment = (elem) => {
    const { ph = '', nodeText = '' } = elem
    // 文字node
    const textVnode = h(
      // HTML tag
      'span',
      // HTML 属性、样式、事件
      {
        props: { contentEditable: false, className: 'phoneme-text-elem' } // HTML 属性,驼峰式写法
      },
      `${nodeText}-${convert(ph)}`
    )
    // 删除按钮节点
    const delVnode = h(
      // HTML tag
      'span',
      // HTML 属性、样式、事件
      {
        props: { contentEditable: false, className: 'phoneme-del-elem iconfont icon-guanbi' }, // HTML 属性,驼峰式写法
        on: {
          click(event) {
            event.preventDefault()
            // 找到路径
            const path = DomEditor.findPath(editor, elem)
            // 选中路径
            editor.select(path)
            // 删除节点
            SlateTransforms.removeNodes(editor, {
              at: path
            })
            // 插入字符节点
            editor.insertNode({
              type: 'text',
              text: nodeText
            })
          }
        }
      }
    )
    // 附件元素 vnode
    const attachVnode = h(
      // HTML tag
      'span',
      // HTML 属性、样式、事件
      {
        props: { contentEditable: false, className: 'phoneme-elem' } // HTML 属性,驼峰式写法
      },
      // 子节点
      [textVnode, delVnode]
    )
    return attachVnode
  }

  // 把 phonemeRenderElemConf 注册到 wangEditor
  Boot.registerRenderElem({
    type: 'phonemeElement', // 新元素 type ,重要!!!
    renderElem: phonemeRenderAttachment
  })

3、创建编辑器实例,技术要点如下:

  1. autoFocus要设置为false,否则会有无法清除聚焦的问题。

  2. 自定义粘贴功能,将粘贴内容的文本提取出来,放弃掉样式信息。

  3. mode设置为simple,禁用掉操作栏,禁止操作样式

editor = createEditor({
    selector: '#editor-container',
    html: '',
    config: {
      placeholder: '请输入',
      autoFocus: false,
      maxLength: 100,
      // 自定义粘贴
      customPaste: (editor, event) => {
        const text = event.clipboardData.getData('text/plain')
        if (text) {
          editor.insertText(text)
        }
        event.preventDefault()
        return false
      }
    },
    mode: 'simple'
  })

4、解析函数HTML-->SSML

此函数的应用场景是:从编辑器提取SSML

import { DomEditor, SlateElement, SlateText } from '@wangeditor/editor'
const escapeText = (text) => {
  const result = text
    .replaceAll(/[&]/gi, '&')
    .replaceAll(/[<]/gi, '&lt;')
    .replaceAll(/[>]/gi, '&gt;')
  return result
}

const serializeSpeak = (node, children) => {
  return `<speak>${children}</speak>`
}

const serializePhoneme = (node, text) => {
  return `<phoneme alphabet="py" ph="${node.ph}">${text}</phoneme>`
}


const serializeNode = (node) => {
  if (SlateText.isText(node)) {
    return escapeText(node.text.trim())
  } else if (SlateElement.isElement(node)) {
    const children = node.children.map((n) => serializeNode(n)).join('')
    const type = DomEditor.getNodeType(node)
    switch (type) {
      case 'paragraph':
        return children
      case 'speakElement':
        return serializeSpeak(node, children)
      case 'phonemeElement':
        return serializePhoneme(node, node.nodeText)
      default:
        return children
    }
  }
  return ''
}

const HTMLToSSML = (nodes) => {
  return `<speak>${serializeNode(nodes[0])}</speak>`
}

技术要点如下:

  1. HTMLToSSML函数的传参是editor.children,通过循环解析节点来转换

  2. 节点类型只区分text和其他

5、解析函数SSML-->HTML

函数的应用场景是:将SSML渲染到编辑器

import { DomEditor, SlateElement, SlateText } from '@wangeditor/editor'
const SSMLToHTML = (text) => {
  const formatNodes = (nodes) => {
    const nodeDatas = []
    for (let index = 0; index < nodes.length; index++) {
      const element = nodes[index]

      switch (element.nodeName) {
        case 'SPEAK':
          return formatNodes(element.childNodes)
        case '#text':
          nodeDatas.push({
            text: element.nodeValue,
          })
          break
        case 'PHONEME':
          nodeDatas.push({
            type: 'phonemeElement',
            ph: element.getAttribute('ph') || '',
            nodeText: element.innerText || '',
            children: [{ text: element.innerText || '' }],
          })
          break
      }
    }

    return nodeDatas
  }

  const parentDom = document.createElement('div')
  parentDom.innerHTML = text
  return formatNodes(parentDom.childNodes)
}

技术要点如下:

  1. 因为HTML5支持完全自定义便签,所以SSML文本在解析上完全可以当作HTML文本处理。

  2. 使用时,使用SlateTransforms.insertNodes(editor, SSMLToHTML(ssmlText))方法,将节点注入到编辑器