基础功能介绍
实现这种效果肯定是要用到比较灵活的富文本编辑器工具的,这里我使用的是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结构来实现编辑器中灵活的插入,替换
该结构通常包含以下几个核心部分:
- type(类型) :每个节点都有一个
"type"字段,用于指定节点的类型,比如"paragraph"、"heading"、"image"等。 - content(内容) :大多数节点可以包含其他节点,在 JSON 中通过
"content"字段表示,这是一个节点数组。文本节点通常作为这些节点的子节点。 - attrs(属性) :节点可能有多个属性,通过
"attrs"字段表示,是一个键值对的集合。比如,对于一个图片节点,可能包含"src"、"alt"等属性。 - 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组件嵌入富文本
组件嵌入富文本编辑器应该是最简单的,官方文档上面也有类似的案例,所以这里我们只用根据案例来写就可以~
-
创建自定义节点
我们需要定义一个自定义 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> </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> </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是我们自定义的节点名称,效果如下:
联动效果
完成自定义节点的挂载之后,我们来实现一下特殊的属性,编辑器内部和外部的联动效果:
-
内部=>外部
实现联动效果离不开组件之间的消息传递。我们先来实现内部向外部传递消息~
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) => {
....
})
})
这样一来,当内部节点属性发生变化时,外部显示也会随之更新。
-
外部=>内部
那么编辑器外部如何去触发内部展示更新呢?由上面的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:!"})
最后
-
“/”命令唤醒
这里的实现因为官网上就有即用的代码示例,所以不多加赘述。另外,本示例代码中也实现了此效果,可以去示例代码中查看
-
联动效果的其他实现
这里的联动效果其实我们完全可以使用pinia来统一管理状态。但因为在预览中,我不想再多装一个pinia,就没有去具体写
-
预览地址: