返回 导航

Vue.js

hangge.com

Vue.js - 富文本编辑器Tiptap使用详解11(自定义右键菜单)

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

十一、自定义右键菜单

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),并在 handlerpreventDefault()。把监听放在 editor.view.dom 上更可靠(因为 editor 可能在不同容器中渲染)。
  • 获取编辑器文档位置:view.posAtCoords({ left, top }) 返回 { pos, inside }posProseMirror 文档位置。若为 null,需要容错处理。
  • 将光标移动到 pos(可选):右键后把光标移动到该位置能让后续 editor.commands 在正确位置生效(例如 insertContent)。上例使用了 tr.setSelection(...near(...))
  • 菜单定位:使用 position: fixed 并把 left/top 设置为 event.clientX/Y。渲染后调用 adjustMenuIntoView() 修正超出可视窗口的情况(右侧或底部溢出)。
  • 菜单关闭:document.clickscrollresizeESC 键等都应隐藏菜单,避免菜单“粘住”。注意在 document.click 中要判断点击目标是否在菜单内(menu.contains(e.target))以避免误隐藏。
  • 菜单项执行:菜单项直接调用 editor.chain().focus().<command></command>().run() 或使用 low-levelview.state.tr 操作以处理复杂情形(例如删除 node)。
  • 节点类型识别:可以通过 state.doc.nodeAt(pos)descendants 方式判断右键位置是否是图片、表格或自定义 node,从而显示上下文菜单项(例如“删除图片”“打开图片”等)。

3,运行测试

(1)右键菜单的菜单项会根据选择内容的不同而变化,比如选中文本是右键菜单会包含“加粗”“斜体”“清除格式”菜单项,以及一些常用的特殊符号。

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

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

全部评论(0)

回到顶部