返回 导航

Vue.js

hangge.com

Vue.js - 富文本编辑器Tiptap使用详解6(自定义顶部工具栏)

作者:hangge | 2026-02-18 09:48
    Tiptapheadless 富文本引擎,即它负责文档模型与命令,但不带 UI。所以我们需要自行实现工具栏(ToolbarMenuBar)以及工具栏上的各种按钮、下拉框。本文通过样例演示如何是实现一个可复用的工具栏,包括:支持 Element UI 下拉、Tailwind 风格、FontAwesome 图标、激活态与禁用态、下拉/更多菜单、可配置按钮数组。

六、自定义顶部工具栏

1,添加依赖

(1)首先我们项目总需要安装 Tiptap 基础依赖,具体安装方法见之前的文章:

(2)为了让工具栏更加美观,我还需安装 Element UITailwind 以及 FontAwesome 依赖。
npm install element-ui
npm install @fortawesome/fontawesome-free

(3)最后在 main.js 中引入 Element UITailwind,以及 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)自定义工具栏的设计原则如下:
(2)自定义工具栏 Toolbar.vue 的完整代码如下:
<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,具体如下:
  • 图标采用 FontAwesomefas fa-bold 等类名)。
  • 组件通过 btn.nameeditor.isActivename, 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)

回到顶部