Vue.js - 富文本编辑器Tiptap使用详解6(自定义顶部工具栏)
作者:hangge | 2026-02-18 09:48
Tiptap 是 headless 富文本引擎,即它负责文档模型与命令,但不带 UI。所以我们需要自行实现工具栏(Toolbar/MenuBar)以及工具栏上的各种按钮、下拉框。本文通过样例演示如何是实现一个可复用的工具栏,包括:支持 Element UI 下拉、Tailwind 风格、FontAwesome 图标、激活态与禁用态、下拉/更多菜单、可配置按钮数组。
(2)为了让工具栏更加美观,我还需安装 Element UI、Tailwind 以及 FontAwesome 依赖。
(3)最后在 main.js 中引入 Element UI、Tailwind,以及 FontAwesome 样式。
六、自定义顶部工具栏
1,添加依赖
(1)首先我们项目总需要安装 Tiptap 基础依赖,具体安装方法见之前的文章:
npm install element-ui npm install @fortawesome/fontawesome-free
(3)最后在 main.js 中引入 Element UI、Tailwind,以及 FontAwesome 样式。
import Vue from 'vue' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' import '@fortawesome/fontawesome-free/css/all.css' // FontAwesome import './assets/tailwind.css' // 如果你已配置 tailwind Vue.use(ElementUI)
2,创建自定义工具栏
(1)自定义工具栏的设计原则如下:
- 职责分离:工具栏负责 UI(按钮、样式),逻辑调用交给 editor.commands。按钮只在点击时调用命令,不直接操作 Editor internals。
- 可配置性:用 JSON 数组描述按钮(name, label, icon, command),让工具栏可复用且易扩展。
- 无副作用优先:按钮只调用命令;状态(active/disabled)通过 editor.isActive() 和 editor.can() 查询。
- 低耦合:Toolbar 接受 editor 作为 prop,不直接依赖父组件状态(便于在多个编辑器间复用)。
<template>
<div :class="['toolbar-root', toolbarClass]" role="toolbar" :aria-label="ariaLabel">
<div class="toolbar-left flex items-center gap-1">
<template v-for="(btn, index) in visibleButtons">
<!-- 下拉按钮(使用 Element UI 的 el-dropdown) -->
<el-dropdown v-if="btn.type === 'dropdown'" trigger="click" :key="index"
@command="onDropdownCommand(btn, $event)">
<el-button :size="size" :disabled="isDisabled(btn)" :class="btnClass(btn)">
<i v-if="btn.icon" :class="btn.icon" aria-hidden="true"></i>
<span v-if="showLabels" class="ml-1">{{ btn.label }}</span>
<i class="fas fa-caret-down ml-2"></i>
</el-button>
<el-dropdown-menu slot="dropdown" class="toolbar-dropdown-menu">
<template v-for="(item, i) in btn.items">
<!-- 颜色选择器菜单项 -->
<div v-if="item.type === 'color-picker'" :key="i"
class="toolbar-color-item" @click.stop>
<el-color-picker ref="colorPicker" v-model="item.value" color-format="hex"
size="mini" :show-alpha="false" class="toolbar-color-picker"
@change="onColorChange(item)" :predefine="[
'#ff0000',
'#00ff00',
'#0000ff',
'#ff9800',
'#9c27b0'
]" />
<span class="toolbar-color-label" @click.stop="openColorPicker()">
{{ item.label }}
</span>
</div>
<!-- 字体菜单项:左侧小预览,右侧文字(点击文字或行都触发 onDropdownCommand) -->
<div v-if="item.type === 'font'" :key="i" class="toolbar-font-item"
@click.stop="onDropdownCommand(btn, item)">
<span class="font-sample" :style="{ fontFamily: item.font }"
aria-hidden="true">Aa</span>
<span class="toolbar-font-label">
{{ item.label }}
</span>
<!-- 可选:显示 active 状态 -->
<i v-if="item._active" class="fas fa-check toolbar-font-active"></i>
</div>
<!-- 普通菜单项 -->
<el-dropdown-item v-else :key="i" :command="item" :disabled="isDisabled(item)">
<i v-if="item.icon" :class="item.icon" class="mr-2"></i>
{{ item.label }}
</el-dropdown-item>
</template>
</el-dropdown-menu>
</el-dropdown>
<!-- 普通按钮 -->
<button v-else :class="btnClass(btn)" :title="btn.title || btn.label"
:aria-pressed="isActive(btn)" :disabled="isDisabled(btn)" :key="index"
@click="handleClick(btn)" :type="'button'">
<i v-if="btn.icon" :class="btn.icon" aria-hidden="true"></i>
<span v-if="showLabels" class="ml-1">{{ btn.label }}</span>
</button>
</template>
<!-- overflow / 更多按钮 -->
<el-dropdown v-if="overflowButtons.length" trigger="click" @command="onMoreCommand">
<el-button :size="size" class="toolbar-more-btn">
<i class="fas fa-ellipsis-h"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-for="(btn, idx) in overflowButtons" :key="idx"
:command="btn" :disabled="isDisabled(btn)" @click.native.stop>
<i v-if="btn.icon" :class="btn.icon" class="mr-2"></i>
{{ btn.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<div class="toolbar-right ml-auto">
<slot name="right"></slot>
</div>
</div>
</template>
<script>
/**
* Toolbar.vue(工具栏)
*
* props:
* - editor: Tiptap Editor 实例(必填)
* - buttons: 按钮配置数组
*
* 每个按钮格式如下:
* {
* label: 按钮文字,
* icon: 图标类名(如 "fas fa-bold"),
* name: 节点/Mark 名称,用于 isActive 判断,
* attrs: 对应 isActive 的属性,
*
* command: string | function | undefined
* - 如果是字符串,会执行 editor.chain().focus()[command]()
* - 如果是函数,直接执行 command(editor)
*
* onClick: 自定义回调函数(editor)
*
* can: string
* - 用于 editor.can().chain().focus()[can]() 判断是否可执行
*
* type: 'dropdown'(可选)
* - 需要提供 items: [{ label, icon, command, onClick, name, attrs }]
* }
*
* - maxVisible: 最多显示多少个按钮,超出的放到 Overflow 菜单
* - size: Element UI 的尺寸 ('small'/'mini')
* - showLabels: 是否显示文字(只显示图标或图标+文字)
* - dark: 是否启用暗色模式
* - ariaLabel: 工具栏的 aria-label
*/
export default {
name: 'Toolbar',
props: {
editor: { type: Object, required: true },
buttons: { type: Array, required: true },
maxVisible: { type: Number, default: 7 },
size: { type: String, default: 'small' },
showLabels: { type: Boolean, default: false },
dark: { type: Boolean, default: false },
ariaLabel: { type: String, default: 'Editor toolbar' },
},
data() {
return { showMore: false }
},
computed: {
// 前 maxVisible 个按钮
visibleButtons() {
return this.buttons.slice(0, this.maxVisible)
},
// 超出 maxVisible 的按钮进入 More 菜单
overflowButtons() {
return this.buttons.slice(this.maxVisible)
},
// 根据 dark 切换样式
toolbarClass() {
return this.dark ? 'toolbar--dark' : 'toolbar--light'
}
},
methods: {
// 通用按钮 class:Tailwind 辅助类 + 激活态 + 禁用态
btnClass(btn) {
const base = 'toolbar-btn inline-flex items-center justify-center px-2 py-1 rounded'
const active = this.isActive(btn) ? ' toolbar-btn--active' : ''
const disabled = this.isDisabled(btn) ? ' opacity-50 cursor-not-allowed'
: ' hover:bg-gray-100'
return base + active + disabled
},
// 判断按钮是否处于 active 状态
isActive(btn) {
if (!this.editor) return false
try {
// 优先根据 name + attrs 判断
if (btn && btn.name) return this.editor.isActive(btn.name, btn.attrs || {})
// 如果只有 command 字符串,无法可靠判断激活态 → 默认 false
if (btn && typeof btn.command === 'string') {
return false
}
} catch (e) {
return false
}
return false
},
// 判断按钮是否禁用
isDisabled(btn) {
if (!this.editor) return true
try {
if (btn && btn.disabled) return true
// 优先使用 btn.can 来测试可执行性
if (btn && btn.can) {
return !this.editor.can().chain().focus()[btn.can]().run()
}
// 如果 command 是字符串,尝试模拟执行判断是否可用
if (btn && typeof btn.command === 'string') {
try {
return !this.editor.can().chain().focus()[btn.command]().run()
} catch (e) {
// 如果无法检测,默认认为是可用的
return false
}
}
} catch (e) {
return false
}
return false
},
// 普通按钮点击事件
handleClick(btn) {
if (!this.editor) return
// 如果按钮提供 onClick,优先执行
if (btn.onClick && typeof btn.onClick === 'function') {
return btn.onClick(this.editor)
}
// command 是函数
if (typeof btn.command === 'function') {
return btn.command(this.editor)
}
// command 是字符串 → 调用 editor.chain().focus()[command]()
if (typeof btn.command === 'string') {
try {
this.editor.chain().focus()[btn.command]().run()
} catch (e) {
// 如果命令需要传参(如 setColor),并提供了 btn.args → 使用 args 调用
if (btn.args) {
try {
this.editor.chain().focus()[btn.command](...btn.args).run()
} catch (err) {
console.warn(err)
}
} else {
console.warn('执行命令失败:', btn.command, e)
}
}
}
},
// 下拉菜单点击命令
onDropdownCommand(btn, item) {
// item 是按钮项
if (!item) return
if (item.onClick && typeof item.onClick === 'function') {
return item.onClick(this.editor)
}
if (typeof item.command === 'string') {
try {
this.editor.chain().focus()[item.command](...(item.args || [])).run()
} catch (e) {
console.warn(e)
}
} else if (typeof item.command === 'function') {
item.command(this.editor)
}
},
// “更多 (More)” 菜单按钮点击
onMoreCommand(payload) {
const btn = payload
this.handleClick(btn)
},
// 自定义颜色
onColorChange(item) {
if (!this.editor || !item.value) return
this.editor
.chain()
.focus()
.setColor(item.value)
.run()
},
// 点击文字时,模拟点击 color-picker
openColorPicker(index) {
const picker = this.$refs.colorPicker?.[0]
if (!picker) return
// Element UI 内部 trigger
const trigger = picker.$el.querySelector(
'.el-color-picker__trigger'
)
trigger && trigger.click()
},
}
}
</script>
<style scoped>
/* Tailwind 可用时会增强样式,这里提供最基础的 fallback CSS */
.toolbar-root {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
}
.toolbar--light {
background: var(--toolbar-bg, #fafafa);
border: 1px solid var(--toolbar-border, #eee);
}
.toolbar--dark {
background: #111827;
border: 1px solid #333;
color: #e5e7eb;
}
.toolbar-btn {
background: transparent;
border: none;
cursor: pointer;
}
.toolbar-btn--active {
background: var(--btn-active-bg, #e6f7ff);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.toolbar-more-btn {
background: transparent;
border: none;
padding: 6px 8px;
border-radius: 6px;
}
/* 颜色选择器菜单项 */
.toolbar-color-item {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
padding-bottom: 4px;
padding-left: 20px;
cursor: default;
white-space: nowrap;
}
.toolbar-color-item:hover {
background-color: #f5f7fa;
}
/* 缩小取色器 color-picker 体积 */
.toolbar-color-picker ::v-deep(.el-color-picker__trigger) {
width: 18px;
height: 18px;
padding: 0;
margin-top: 5px;
}
/* 取色器右侧文字 */
.toolbar-color-label {
font-size: 13px;
color: #606266;
cursor: pointer;
user-select: none;
}
/* 字体菜单项(与 dropdown item 高度一致)*/
.toolbar-font-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
cursor: pointer;
white-space: nowrap;
}
.toolbar-font-item:hover {
background-color: #f5f7fa;
}
/* 左侧小样例 */
.font-sample {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 20px;
border-radius: 4px;
font-size: 12px;
line-height: 1;
box-sizing: border-box;
border: 1px solid rgba(0, 0, 0, 0.06);
background: white;
}
/* 右侧 label */
.toolbar-font-label {
font-size: 13px;
color: #606266;
}
/* active 标识(可用勾或高亮) */
.toolbar-font-active {
margin-left: auto;
color: #409eff;
}
</style>
3,使用样例
(1)下面演示如何在父组件中使用这个自定义 Toolbar,具体如下:
- 图标采用 FontAwesome(fas fa-bold 等类名)。
- 组件通过 btn.name 与 editor.isActive(name, attrs) 判断是否高亮(激活)。在按钮配置中尽量传 name(扩展/mark/node 名称),否则无法自动判定。
- 禁用态优先使用 btn.can 指定可执行命令名称(如 toggleBulletList),组件会通过 editor.can() 检测可执行性;若 command 是自定义函数则 isDisabled 可能无法精确判断(这时可以在按钮配置里直接传 disabled: true/false 或动态控制)。
- 下拉使用 Element UI el-dropdown 实现下拉菜单(方便且样式统一)。type: 'dropdown' 的按钮需要包含 items(每个 item 可有 label, icon, command, onClick, args)。
- 更多/overflow 功能单实现用 maxVisible 切分数组,多余的放到更多菜单里(overflowButtons)。更高级的 ResizeObserver 自动折叠可按需增加。
- 当 command 是字符串但需要参数(如 toggleHeading),推荐用 onClick 或在 command 提供 args,或者直接把 command 写成函数(editor => editor.chain()...run())。代码示例展示了多种用法。
<template>
<div class="p-4">
<Toolbar
:editor="editor"
:buttons="buttons"
:maxVisible="12"
:showLabels="false"
:dark="false"
size="small"
v-if="editor"
>
<template #right>
<div class="flex items-center gap-2">
<el-button size="small" @click="saveContent">保存</el-button>
</div>
</template>
</Toolbar>
<div class="mt-3 border rounded">
<editor-content :editor="editor" class="prose p-4 min-h-[200px]" />
</div>
</div>
</template>
<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { EditorContent } from '@tiptap/vue-2'
import Toolbar from './Toolbar.vue'
// 可选扩展(链接、图片、下划线、删除线等)
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import Strike from '@tiptap/extension-strike'
import Image from '@tiptap/extension-image'
export default {
components: { Toolbar, EditorContent },
data() {
return {
editor: null,
buttons: []
}
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit, Link, Underline, Strike, Image],
content: `<p>示例文本:试试选择文字并点击工具栏按钮(或使用快捷键)</p>`
})
/**
* 丰富的工具栏按钮示例
* 包含:
* - 普通命令(toggleBold 等)
* - 下拉菜单(标题)
* - 自定义 onClick 行为(插入链接/图片)
* - 复杂 command 函数写法
*/
this.buttons = [
{ label: '加粗', icon: 'fas fa-bold', name: 'bold', command: 'toggleBold' },
{ label: '斜体', icon: 'fas fa-italic', name: 'italic', command: 'toggleItalic' },
{ label: '下划线', icon: 'fas fa-underline', name: 'underline', command: 'toggleUnderline' },
{ label: '删除线', icon: 'fas fa-strikethrough', name: 'strike', command: 'toggleStrike' },
{ label: '行内代码', icon: 'fas fa-code', name: 'code', command: 'toggleCode' },
{ label: '引用', icon: 'fas fa-quote-right', command: 'toggleBlockquote',
name: 'blockquote' },
// 列表
{ label: '项目符号', icon: 'fas fa-list-ul', command: 'toggleBulletList',
name: 'bulletList' },
{ label: '编号列表', icon: 'fas fa-list-ol', command: 'toggleOrderedList',
name: 'orderedList' },
// 下拉:标题级别选择
{
type: 'dropdown',
label: '标题',
icon: 'fas fa-heading',
items: [
{ label: '正文 (p)', command: (editor) => editor.chain().focus().setParagraph().run() },
{ label: 'H1', command: 'toggleHeading', args: [1],
onClick: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: 'H2', command: 'toggleHeading', args: [2],
onClick: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: 'H3', command: 'toggleHeading', args: [3],
onClick: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run() },
],
},
// 自定义链接插入(手动 prompt)
{
label: '插入链接', icon: 'fas fa-link',
onClick: (editor) => {
const url = prompt('请输入 URL (以 http(s):// 开头):')
if (url) editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
},
// 图片(示例:插入图片)
{
label: '插入图片', icon: 'far fa-image',
onClick: (editor) => {
const url = prompt('图片 URL:')
if (url) editor.chain().focus().setImage({ src: url }).run()
}
},
// 自定义清除格式
{ label: '清除格式', icon: 'fas fa-eraser',
onClick: (editor) => editor.chain().focus().clearNodes().unsetAllMarks().run() },
// 代码块、撤销、重做
{ label: '代码块', icon: 'fas fa-file-code', command: 'toggleCodeBlock' },
{ label: '撤销', icon: 'fas fa-undo',
command: (editor) => editor.chain().focus().undo().run() },
{ label: '重做', icon: 'fas fa-redo',
command: (editor) => editor.chain().focus().redo().run() },
]
},
beforeDestroy() {
if (this.editor) this.editor.destroy()
},
methods: {
saveContent() {
const html = this.editor.getHTML()
alert('保存的 HTML 长度:' + html.length)
}
}
}
</script>
<style scoped>
/* 示例样式(如项目中已有 Tailwind,可省略) */
.prose { /* 基本文本样式 */ }
.toolbar-root { padding: 6px; }
</style>
(2)运行后效果如下,由于我们设置了最大显示 12 个按钮,超过部分会放置到右侧更多下拉框中。

全部评论(0)