Vue.js - 富文本编辑器Tiptap使用详解11(自定义右键菜单)
作者:hangge | 2026-02-27 08:46
在网页中,鼠标右键默认会弹出浏览器的上下文菜单。很多富文本编辑器会替换默认菜单,提供与编辑器语义相关的操作(如插入特殊符号、快速格式化、图片操作、链接编辑等)。本文演示在 Tiptap 中如何拦截右键(contextmenu),阻止默认行为,并显示一个自定义的右键菜单。
(2)关键代码详解:


十一、自定义右键菜单
1,实现思路
(1)在编辑器渲染的 DOM 上监听 contextmenu 事件。
(2)preventDefault() 阻止浏览器默认菜单。
(3)根据鼠标坐标和编辑器内容(可用 editor.view.posAtCoords({ left, top }))计算编辑器文档位置(pos)。
(4)把选区移动到该位置(或在不改变选区的情况下记录 pos),并显示一个绝对定位的菜单(<div class="ctx-menu">)。
(5)菜单项调用 editor.commands 执行相应操作(插入符号、切换加粗/斜体、插入链接、删除节点、复制节点 url 等)。
(6)点击空白处、按 ESC、滚动或编辑器失焦时隐藏菜单。
(7)保证菜单在屏幕内(对 left/top 做边界调整)。
2,实现代码
(1)下面是实现自定右键菜单的完整代码:
<template>
<div class="editor-wrapper" ref="root">
<!-- 编辑器 -->
<editor-content :editor="editor" class="editor-content" />
<!-- 自定义右键菜单(绝对定位) -->
<div v-if="menu.visible" class="ctx-menu"
:style="{ left: menu.x + 'px', top: menu.y + 'px' }" ref="menu"
role="menu" @keydown.stop.prevent="onMenuKeydown" tabindex="-1">
<!-- 菜单头(可显示上下文信息) -->
<div class="ctx-menu__title" v-if="menu.nodeType">{{ menu.nodeType }}</div>
<!-- 快速格式化 -->
<button class="ctx-item" @click="toggleBold" role="menuitem">加粗</button>
<button class="ctx-item" @click="toggleItalic" role="menuitem">斜体</button>
<button class="ctx-item" @click="clearFormatting" role="menuitem">清除格式</button>
<div class="ctx-sep"></div>
<!-- 链接相关:如果选区在链接上,显示编辑/移除 -->
<button v-if="isLinkAtSelection" class="ctx-item" @click="editLink" role="menuitem">
编辑链接</button>
<button v-if="isLinkAtSelection" class="ctx-item" @click="unsetLink" role="menuitem">
移除链接</button>
<!-- 插入特殊符号 -->
<div class="ctx-subtitle">插入符号</div>
<div class="symbol-row">
<button class="symbol" v-for="s in symbols" :key="s" @click="insertSymbol(s)"
role="menuitem">{{ s }}</button>
</div>
<div class="ctx-sep"></div>
<!-- 节点操作示例(图片/附件等)-->
<button v-if="menu.nodeType === 'image'" class="ctx-item" @click="openImageInNewTab"
role="menuitem">在新标签打开图片</button>
<button v-if="menu.nodeType === 'image'" class="ctx-item" @click="deleteNodeAtPos"
role="menuitem">删除图片</button>
</div>
</div>
</template>
<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { EditorContent } from '@tiptap/vue-2'
export default {
name: 'EditorWithContextMenu',
components: { EditorContent },
data() {
return {
editor: null,
menu: {
visible: false,
x: 0,
y: 0,
pos: null, // 文档中的位置(pos)
nodeType: null, // 当前节点类型(如 'image')
},
// 特殊符号示例
symbols: ['©', '®', '™', '✓', '—', '…', '€', '£'],
}
},
computed: {
isLinkAtSelection() {
if (!this.editor) return false
// 判断当前选区是否处于 link mark
return this.editor.isActive('link')
}
},
mounted() {
// 初始化 editor
this.editor = new Editor({
extensions: [StarterKit, Link, Image],
content: `<p>右键编辑器区域查看自定义右键菜单示例。试试在链接、文字或图片上右键。</p>
<p>示例图片:</p>
<p><img src="https://www.hangge.com/blog/images/logo.png" alt="logo"></p>
<p>示例链接:<a href="https://www.hangge.com">hangge.com</a></p>`,
})
// 在编辑器 DOM 上监听 contextmenu(右键)
// 注意:editor.options.element 或 editor.view.dom 都可用;使用 editor.view.dom 更保险
const dom = this.editor.view.dom
if (dom) {
dom.addEventListener('contextmenu', this.onContextMenu)
}
// 点击其他区域关闭菜单
document.addEventListener('click', this.onDocumentClick)
window.addEventListener('resize', this.hideMenu)
window.addEventListener('scroll', this.hideMenu, true) // 捕获阶段,滚动时隐藏
},
beforeDestroy() {
// 清理事件
if (this.editor && this.editor.view && this.editor.view.dom) {
this.editor.view.dom.removeEventListener('contextmenu', this.onContextMenu)
}
document.removeEventListener('click', this.onDocumentClick)
window.removeEventListener('resize', this.hideMenu)
window.removeEventListener('scroll', this.hideMenu, true)
if (this.editor) this.editor.destroy()
},
methods: {
// 右键事件处理器
onContextMenu(event) {
// 只在编辑器内部响应(已经绑定到 editor.dom,但做保险检查)
if (!this.editor) return
// 防止浏览器默认上下文菜单
event.preventDefault()
event.stopPropagation()
// 获取文档位置(posAtCoords 需要编辑器的 view)
const view = this.editor.view
const rect = view.dom.getBoundingClientRect()
const left = event.clientX
const top = event.clientY
// posAtCoords 可能返回 null(例如在空白处),做容错
const posResult = view.posAtCoords({ left, top })
const pos = posResult ? posResult.pos : null
// 记录 pos(后续执行命令时使用)
this.menu.pos = pos
// 取出节点信息(若点击处在节点上)
let nodeType = null
if (pos != null) {
// 找到该位置的 node(descendants or nodeAt)
const node = view.state.doc.nodeAt(pos)
if (node) {
nodeType = node.type.name
} else {
// 当在文字内部,nodeAt(pos) 有时为 null —— 改为查最近的父节点
view.state.doc.descendants((n, p) => {
if (p <= pos && pos < p + n.nodeSize) {
nodeType = n.type.name
return false
}
return true
})
}
}
this.menu.nodeType = nodeType
const { from, to, empty } = view.state.selection
if (pos != null) {
// 如果当前已有非空选区,并且右键位置位于该选区内,则保持原选区,不做修改
if (!empty && pos >= from && pos <= to) {
// 保持原选区(不改变 selection)
} else {
// 否则将光标/选区移动到右键位置(折叠或设置光标)
const tr = view.state.tr.setSelection(
view.state.selection.constructor.near(view.state.doc.resolve(pos))
)
view.dispatch(tr)
}
}
// 计算菜单显示坐标(相对于视口),并确保不超出窗口边界
this.showMenuAt(event.clientX, event.clientY)
},
showMenuAt(clientX, clientY) {
this.$nextTick(() => {
const menuEl = this.$refs.menu
if (!menuEl) {
// 菜单 UI 还未渲染,设置初始位置再调整
this.menu.x = clientX
this.menu.y = clientY
this.menu.visible = true
this.$nextTick(() => this.adjustMenuIntoView())
return
}
this.menu.x = clientX
this.menu.y = clientY
this.menu.visible = true
this.$nextTick(() => this.adjustMenuIntoView())
// focus 菜单以支持键盘操作
if (menuEl && typeof menuEl.focus === 'function') menuEl.focus()
})
},
// 确保菜单在视窗口内(调整 left/top)
adjustMenuIntoView() {
const menuEl = this.$refs.menu
if (!menuEl) return
const pad = 8
const rect = menuEl.getBoundingClientRect()
let x = this.menu.x
let y = this.menu.y
const vw = window.innerWidth
const vh = window.innerHeight
if (x + rect.width + pad > vw) x = Math.max(pad, vw - rect.width - pad)
if (y + rect.height + pad > vh) y = Math.max(pad, vh - rect.height - pad)
this.menu.x = x
this.menu.y = y
},
// 点击文档时隐藏菜单(点击菜单本身不触发)
onDocumentClick(e) {
const menuEl = this.$refs.menu
if (!menuEl) return
if (menuEl.contains(e.target)) return
this.hideMenu()
},
hideMenu() {
this.menu.visible = false
this.menu.pos = null
this.menu.nodeType = null
},
// 菜单键盘处理(支持 ESC 关闭)
onMenuKeydown(e) {
if (e.key === 'Escape' || e.key === 'Esc') {
this.hideMenu()
}
// 还可实现上下键选择菜单项、回车激活等(略)
},
// 操作:切换加粗
toggleBold() {
if (!this.editor) return
this.editor.chain().focus().toggleBold().run()
this.hideMenu()
},
toggleItalic() {
if (!this.editor) return
this.editor.chain().focus().toggleItalic().run()
this.hideMenu()
},
clearFormatting() {
if (!this.editor) return
this.editor.chain().focus().clearNodes().unsetAllMarks().run()
this.hideMenu()
},
// 插入特殊符号
insertSymbol(s) {
if (!this.editor) return
// 将符号插到当前位置(或 menu.pos)
// 这里使用 insertContent,确保在当前光标位置插入
this.editor.chain().focus().insertContent(s).run()
this.hideMenu()
},
// 链接编辑与移除
editLink() {
if (!this.editor) return
const previous = this.editor.getAttributes('link').href || ''
const url = prompt('请输入链接 URL(留空移除)', previous)
if (url === null) { this.hideMenu(); return }
if (url === '') {
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
} else {
this.editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
this.hideMenu()
},
unsetLink() {
if (!this.editor) return
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.hideMenu()
},
// 节点相关(以 pos 为基准删除或打开图片)
deleteNodeAtPos() {
const pos = this.menu.pos
if (pos == null || !this.editor) return
// 找到该位置的 node 以及其起始位置(parent)
const { state, view } = this.editor
const node = state.doc.nodeAt(pos)
if (!node) {
alert('未找到节点')
return
}
// 删除节点:以 pos 为起点,删除 node.nodeSize
const tr = state.tr.delete(pos, pos + node.nodeSize)
view.dispatch(tr)
this.hideMenu()
},
openImageInNewTab() {
const pos = this.menu.pos
if (pos == null || !this.editor) return
const node = this.editor.state.doc.nodeAt(pos)
if (!node || node.type.name !== 'image') return
const src = node.attrs.src
if (src) window.open(src, '_blank')
this.hideMenu()
},
},
}
</script>
<style scoped>
.editor-content {
min-height: 220px;
border: 1px solid #e6e6e6;
border-radius: 6px;
padding: 12px;
background: #fff;
}
/* 右键菜单样式 */
.ctx-menu {
position: fixed;
z-index: 9999;
min-width: 220px;
background: #fff;
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
padding: 8px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
color: #111827;
outline: none;
}
/* 菜单标题 */
.ctx-menu__title {
font-weight: 600;
font-size: 12px;
color: #374151;
padding: 6px 8px;
}
/* 菜单项样式 */
.ctx-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #111827;
border-radius: 4px;
}
.ctx-item:hover {
background: #f3f4f6;
}
/* 分隔符 */
.ctx-sep {
height: 1px;
background: #eef2f7;
margin: 8px 0;
}
/* 特殊符号行 */
.symbol-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 4px;
}
.symbol {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #e6e6e6;
background: #fff;
cursor: pointer;
}
.symbol:hover {
background: #f8fafc;
}
.ctx-subtitle {
font-size: 12px;
color: #6b7280;
margin-top: 6px;
margin-bottom: 4px;
padding-left: 4px
}
</style>
(2)关键代码详解:
- 拦截右键:view.dom.addEventListener('contextmenu', onContextMenu),并在 handler 中 preventDefault()。把监听放在 editor.view.dom 上更可靠(因为 editor 可能在不同容器中渲染)。
- 获取编辑器文档位置:view.posAtCoords({ left, top }) 返回 { pos, inside },pos 为 ProseMirror 文档位置。若为 null,需要容错处理。
- 将光标移动到 pos(可选):右键后把光标移动到该位置能让后续 editor.commands 在正确位置生效(例如 insertContent)。上例使用了 tr.setSelection(...near(...))。
- 菜单定位:使用 position: fixed 并把 left/top 设置为 event.clientX/Y。渲染后调用 adjustMenuIntoView() 修正超出可视窗口的情况(右侧或底部溢出)。
- 菜单关闭:document.click、scroll、resize、ESC 键等都应隐藏菜单,避免菜单“粘住”。注意在 document.click 中要判断点击目标是否在菜单内(menu.contains(e.target))以避免误隐藏。
- 菜单项执行:菜单项直接调用 editor.chain().focus().<command></command>().run() 或使用 low-level 的 view.state.tr 操作以处理复杂情形(例如删除 node)。
- 节点类型识别:可以通过 state.doc.nodeAt(pos) 或 descendants 方式判断右键位置是否是图片、表格或自定义 node,从而显示上下文菜单项(例如“删除图片”“打开图片”等)。
3,运行测试
(1)右键菜单的菜单项会根据选择内容的不同而变化,比如选中文本是右键菜单会包含“加粗”“斜体”“清除格式”菜单项,以及一些常用的特殊符号。

(2)并且当在图片上面右键点击时,菜单项会多出“在新标签打开图片”和“删除图片”这两个菜单项。

(3)而在链接上单击右键,则右键菜单会多出“编辑链接”“移除链接”这两个菜单项。

全部评论(0)