朋友,或许你有点好奇挖空式提示语交互如何实现🍔

381 阅读7分钟

基础功能介绍


实现这种效果肯定是要用到比较灵活的富文本编辑器工具的,这里我使用的是tiptap,实现的难点主要是在:

  • 在富文本编辑器中,插入自定义的vue组件
  • 特殊的值(在这里的话就是,图片风格,图片比例),编辑器内部和外部双向绑定
  • 组件中placeholder效果实现

这里会涉及tiptap的扩展使用,如果有看不懂的地方,可以查阅官方文档

说实话,这里placholder的效果实现最难,其次就是编辑器内部的属性和外部互通,写起来会有一点麻烦,不过根据官方文档,可以按部就班的写出来,下面来看看实现吧~

一些前置提要


Tiptap 是 ProseMirror 的无头包装器,允许您使用模块化构建块创建完全可定制的富文本编辑器。Tiptap 可跨多个框架工作,包括 React、Vue、Svelte 等。

tiptap官网地址:tiptap.dev/docs/exampl…

关于Node节点和扩展

如果您将文档视为一棵树,那么节点只是该树中的一种内容。节点的示例是段落、标题或代码块。但节点不一定是块。它们也可以与文本内联呈现,例如@提及,这主要根据节点的inline设置。

除了现有的Node节点类型,Tiptap允许我们通过Node类添加自定义节点,比方说下面就是官方对于文本节点的扩展定义:

import { Node } from '@tiptap/core'

/**
 * This extension allows you to create text nodes.
 * @see https://www.tiptap.dev/api/nodes/text
 */
export const Text = Node.create({
  name: 'text',
  group: 'inline',
})

JSON结构

Tiptap 的 JSON 结构是其底层文档模型的一种表示方式,基于 ProseMirror 的数据结构,用于描述编辑器内容的层次和属性。我们定义JSON结构来实现编辑器中灵活的插入,替换

该结构通常包含以下几个核心部分:

  1. type(类型) :每个节点都有一个 "type" 字段,用于指定节点的类型,比如 "paragraph""heading""image" 等。
  2. content(内容) :大多数节点可以包含其他节点,在 JSON 中通过 "content" 字段表示,这是一个节点数组。文本节点通常作为这些节点的子节点。
  3. attrs(属性) :节点可能有多个属性,通过 "attrs" 字段表示,是一个键值对的集合。比如,对于一个图片节点,可能包含 "src""alt" 等属性。
  4. text(文本) :对于包含文本的节点(通常是叶子节点,如文本段落、标题的内容),会有一个 "text" 字段表示节点所包含的纯文本内

例如下面的JSON,展示的就是一个有序列表:

{
  "type": "doc",
  "content": [
    {
      "type": "orderedList",
      "attrs": {
        "order": 1
      },
      "content": [
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "content": [
                {
                  "type": "text",
                  "text": "第一项"
                }
              ]
            }
          ]
        },
      ]
    }
  ]
}

在这个示例中:

  • 文档的根节点是 "doc" 类型。

  • "orderedList" 节点表示有序列表,"attrs" 字段中的 "order": 1 指定列表的起始编号。

  • "orderedList" 包含多个 "listItem" 节点,每一个都是列表中的一项。

  • 每个 "listItem" 节点中包含一个 "paragraph" 节点,里面包含 "text" 节点,这些文本节点表示具体的列表项内容。

vue组件嵌入富文本

组件嵌入富文本编辑器应该是最简单的,官方文档上面也有类似的案例,所以这里我们只用根据案例来写就可以~

  1. 创建自定义节点

我们需要定义一个自定义 Node 用于承载 Vue 组件。使用 Tiptap 提供的 Node 类注册,使用 VueNodeViewRenderer 方法中挂载VUE组件

export default Node.create({
  name: 'highlightElements',

  group: 'inline',

  inline: true,
  content: 'inline*',
  selectable: false, // 节点不可分割
  
  //...其他配置
  
  parseHTML() {
    return [
      {
        tag: 'highlight-elements',
      },
    ]
  },

  renderHTML({ HTMLAttributes }) {
    return ['highlight-elements', mergeAttributes(HTMLAttributes)]
  },
  addAttributes() {
      //...见下方配置
  }

  addNodeView() {
    return VueNodeViewRenderer(Component as any) // 挂载vue组件
  },
})

根据需求,我们需要扩展的组件有两种展示形式:select选择器,类似input的输入框。

我们可以在扩展文件文件中,定义节点需要数据。这些数据会随着props传入vue组件中,方便我们调用

type highlightElementsType = 'text' | 'select'
interface TextMsg {
  placeholder: string
  value?: string
}
interface SelectMsg {
  value: string
  options: Array<{ label: string; value: string }>
  title: string
}
addAttributes() {
    return {
      type: {
        default: 'text',
        type: String as PropType<highlightElementsType>,
      },
      selectMsg: {
        default: {
          value: '',
          options: [],
          title: '',
        },
        type: Object as PropType<SelectMsg>,
      },
      textMsg: {
        default: {
          value: '',
          placeholder: '',
        },
        type: Object as PropType<TextMsg>,
      },
    }
  },

2. ## 定义VUE组件

Tiptap会将一些非常有用的 props 传递给您的自定义 Vue 组件,以下获取props:

import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
const props = defineProps(nodeViewProps)

props传递的数据其中之一是node道具,它允许你直接访问该节点。比方说,假设您已将名为count的属性添加到节点扩展中,您可以像这样访问它:

props.node.attrs.count

甚至可以借助传递给组件的updateAttributes属性更新节点属性:

props.updateAttributes({
  count: this.node.attrs.count + 1,
})

根据上面的信息,我们可以直接根据node工具提供的信息来编写vue组件中的一些逻辑:

<script setup lang="ts">
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
const props = defineProps(nodeViewProps)
</script>

<template>
  <NodeViewWrapper as="span">
    <span>&nbsp;</span>
    <a-select v-if="props.node.attrs.type === 'select'" style="margin-inline: 1px;" :options="props.node.attrs.selectMsg.options" :value="props.node.attrs.selectMsg.value" class="selector" @change="handleChange">
    </a-select>
    <span v-else class="text" contenteditable="true" :placeholder="props.node.attrs.textMsg.placeholder">{{ props.node.attrs.textMsg.value }}</span>
    <span>&nbsp;</span>
  </NodeViewWrapper>
</template>

3. ## 编辑器嵌入自定义组件

前面的步骤,完成了自定义节点和vue组件的联动,现在我们需要将自定义节点在编辑器中注册

// 自定义组件节点
import highlightElements from './HighlightElement/highlightElements.ts'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'

const editor = useEditor({
  content: '',
  extensions: [
    StarterKit, // 基础的一些节点配置,不必在意
    highlightElements, // 将自定义节点扩展挂载
  ],
})

<template>
     <EditorContent id="tiptap-textarea" :editor="editor" style="outline: none;max-height: 240px;overflow: auto;" />
</template>

那么我现在如何将组件渲染进入编辑器内部呢?

Tiptap的编辑器实例中,很多方法都允许我们使用JSON结构,将内容插入指定位置,例如我们下面使用的insertContent

const docJson = {
    "type": "doc",
    "content": [
        {
            "type": "paragraph",
            "content": [
                {
                    "type": "highlightElements",
                    "attrs": {
                        "type": "select",
                        "selectMsg": {
                            "value": "5",
                            "options": [
                                {
                                    "label": "水彩",
                                    "value": "1"
                                },
                            ],
                            "title": "style"
                        },
                        "textMsg": {
                            "value": "",
                            "placeholder": ""
                        }
                    }
                },
            ]
        }
    ]
}
// 使用实力的setContent方法,替换编辑器内容
editor.value?.chain().setContent(docJson).run()

其中highlightElements是我们自定义的节点名称,效果如下:

联动效果

完成自定义节点的挂载之后,我们来实现一下特殊的属性,编辑器内部和外部的联动效果:


  1. 内部=>外部

实现联动效果离不开组件之间的消息传递。我们先来实现内部向外部传递消息~

props除了传递node工具之外,还传递了eidtor编辑器实例,editor实例中挂载了方法emit,用于触发和管理自定义事件

假设您需要在某个操作后弹出一个消息,您可以在自定义命令或节点里使用 emit 事件:

editor.emit('custom-event', { message: '操作成功' });

然后在外部,您可以监听这个事件:

editor.on('custom-event', ({ message }) => {console.log(message); // 输出: 操作成功
});

现在我们基于props提供的工具,进行组件内部向外部发送信息

// vue组件中:
<script setup lang="ts">
const handleChange = (value: string) => {
  const { options, title } = props.node.attrs.selectMsg
  props.updateAttributes({
    selectMsg: {
      value,
      options,
      title,
    },
  })
  props.editor.emit(title, value)
}
</script>

<template>
    <a-select :value="props.node.attrs.selectMsg.value" @change="handleChange">
</template>

// 编辑器实例所在组件:
onMounted(() => {
  editor.value?.on('style', (value: any) => {
      ....
  })
})

这样一来,当内部节点属性发生变化时,外部显示也会随之更新。

  1. 外部=>内部

那么编辑器外部如何去触发内部展示更新呢?由上面的vue组件实现可知,展示的值其实就是节点的selectMsg.value属性。所以实际上,我们的目的其实就是,改变该节点的属性

在自定义节点中,添加addCommands,注册updatePicAttr命令,该命令会与节点一起挂载到编辑器实例上。

addCommands() {
    return {
      updatePicAttr: ({ type, value }): Command => ({ tr, state, dispatch, commands }) => {
        const { doc } = state
        let hasFound = false

        doc.descendants((node, pos) => {
          if (hasFound) return false
          if (node.type.name === 'highlightElements'
              && node.attrs.type === 'select'
              && node.attrs.selectMsg?.title === type) {
            const transaction = tr.setNodeAttribute(pos, 'selectMsg', {
              ...node.attrs.selectMsg,
              value,
            })
            if (dispatch) dispatch(transaction)
            hasFound = true
            return false
          }
        })
        return true
      },
    }
  },

其中:

  • doc.descendants:

    • 遍历文档中的所有节点。
    • node 是当前遍历到的节点,pos 是节点的位置。
  • 更新节点属性:

    • 如果找到匹配的节点,使用 tr.setNodeAttribute 更新节点的 selectMsg 属性中的 value。
    • 使用 dispatch 应用事务。
  • 返回值:始终返回 true,表示命令执行成功。

当外部使用时,我们就可以通过编辑器实例,调用上面定义的updatePicAttr命令,实现外部更新编辑器内部展示的效果:

  // 使用:将编辑器中图片比例设置为:“1:1”
  editor.value?.commands.updatePicAttr({ "ratio", value: "1:!"})

最后

  1. “/”命令唤醒

这里的实现因为官网上就有即用的代码示例,所以不多加赘述。另外,本示例代码中也实现了此效果,可以去示例代码中查看

  1. 联动效果的其他实现

这里的联动效果其实我们完全可以使用pinia来统一管理状态。但因为在预览中,我不想再多装一个pinia,就没有去具体写

  1. 预览地址:

stackblitz.com/edit/tiptap…