是的,自定义右键菜单再次尝试重构了。上一次重构是在 2022 年,但最近在尝试修改时发现,目前实现逻辑过于复杂且不够优雅,同时由于缺乏合适的注释,花了不少时间才理清调用关系, 惭愧。
因此,决定推倒重来,重新设计。
一、需求分析
菜单类型分为两类:导航栏和菜单项。导航栏:位于顶部(类似火狐浏览器的右键菜单),采用横向布局,仅显示图标。菜单项:采用竖向布局,显示图标和菜单名称,对应一般的普通菜单。
功能上也可分为两类:链接型菜单和事件型菜单。链接型菜单:记录网址和链接类型,例如:回到首页、查看留言板页面。事件型菜单:需要执行函数的菜单项,例如:复制文本、打印页面。
详细分析见下面的思维导图:

1.1 配置文件
Hexo 配置文件示例rightmenus: enable: true options: navigation: true maxMenuItems: 12 navigation: - { menuItem } menuList: - { menuItem }
|
navigation
和menuList
字段的定义顺序决定了菜单加载的显示顺序。在options
中,navigation
控制是否显示导航栏组件;当满足条件的菜单项数量超过maxMenuItems
时,隐藏所有链接型菜单。在menuList
菜单项中,如果相邻菜单项的displayCondition
值不同,则自动添加分割线。也可以手动定义菜单分割线,此时自动判断会关闭,并在定义的位置添加分割线。
菜单分割线定义方式对于 eventName 和 displayCondition 的值,右键菜单函数会预设一些事件和判断函数。在进行菜单项判断或点击事件触发时,首先会检查右键菜单函数中是否存在相应的实现。如果不存在,则会在 window 上尝试调用,同时传递 menuItem、event 和 pointevent 值。具体的调用方式如下:
eventName
window[eventName](menuItem, event, pointevent)()
|
displayCondition
window[displayCondition](menuItem, pointevent)()
|
该调用支持简单的点分链式调用、参数传递和文本替换。详细的实现逻辑请见:3.3 外部方法。
使用示例- { eventName: 'readMode' } - { eventName: 'window.location.reload' } - { eventName: 'volantis.dark.toggle' } - { eventName: 'window.open(linkAddress)' } - { eventName: 'OpenSearch(selectedText)' } - { eventName: 'window.open("https://www.bing.com/search?q=##selectedText##")' }
|
1.2 页面绘制
menuItem 中的属性将以data-xxxx
格式写入到菜单项的 HTML 元素上。
例外属性: linkTarget ➡ target, link ➡ href<% if(item.link === undefined) { %> <li class="menuLoad-Content"> <span class="vlts-menu" data-id="<%- item.id %>" data-event-name="<%= item.eventName %>" data-display-condition="<%- item.displayCondition %>"> <i class="<%- item.icon %>"></i> <%- item.name %> </span> </li> <% } else { %> <% if(!item.linkTarget) item.linkTarget = '_self' %> <li class="menuLoad-Content"> <a class="vlts-menu" data-id="<%- item.id %>" href="<%- item.link %>" target="<%- item.linkTarget %>" data-display-condition="<%- item.displayCondition %>"> <i class="<%- item.icon %>"></i> <%- item.name %> </a> </li> <% } %>
|
二、处理流程
大致处理流程如下:
👐 使用右键选择器在页面中找到 HTML 元素。
🙌 如果找到,则读取所有导航栏和菜单项赋值存储。
🤲 覆盖浏览器默认的右键菜单,绘制菜单项并显示自定义右键菜单。
🙏 为右键菜单点击事件添加事件委托,触发相应事件。

三、功能实现
如果是传统的 BS 项目,菜单的读取通常是通过调用 API 来完成的。言归正传,在这里,我们从页面中查找右键元素后,分别读取出导航栏和菜单项中的元素,并将它们赋值给 navigationItems 和 menuItems。
const menuItems = Array.from(menuContainer.querySelectorAll('.menuLoad-Content')) .flatMap(item => { const elem = item.firstElementChild; return elem ? [{ id: elem.dataset.id, link: elem.href, linkTarget: elem.target, eventName: elem.dataset.eventName, displayCondition: elem.dataset.displayCondition, isHrElement: elem.tagName === 'HR', menuContentElement: item }] : []; });
|
3.1 菜单绘制
主要涉及两个方面:菜单项的显示与隐藏、菜单的弹出位置。首先,我们定义一个conditions
对象,其中包含基本的判断条件,这些条件的方法返回布尔值。这样,只需将displayCondition
的值与conditions
的键值进行判断,即可区分内置判断条件和外部判断条件。目前内置了以下判断项目:
const conditions = { inInputField: () => {}, selectedText: () => {}, onImage: () => {}, onLink: () => {}, articlePage: () => {}, scrolledFromTop: () => {}, homePage: () => {} }
|
根据设定,如果displayCondition
未定义,菜单项默认显示。这可能会导致显示的菜单项过多。在这种情况下,可以通过配置文件中的maxMenuItems
值来控制显示数量。当显示数量超过此值时,隐藏所有链接型菜单项。除此之外,对于菜单分割项也需要额外处理,主要包括以下两点:
- 因菜单项隐藏而导致相邻菜单分割项显示的场景。
- 第一个或最后一个显示项为菜单分割项的场景。
let lastSeparator = null; menuItems.forEach(menuItem => { const classList = menuItem?.menuContentElement?.classList || null; if (menuItem.isHrElement) { classList.add('active'); if (lastSeparator) { lastSeparator.remove('active'); } lastSeparator = classList; } else if (classList.contains('active')) { lastSeparator = null; } }); if (lastSeparator) { lastSeparator.remove('active'); } if (navigationItems.length === 0) { const firstActiveMenuItem = menuItems.find( item => item.menuContentElement.classList.contains('active')) if (firstActiveMenuItem.isHrElement) { firstActiveMenuItem.menuContentElement.classList.remove('active'); } }
|
最后,关于菜单的弹出位置,只需确保菜单显示在屏幕内即可。另一个需要考虑的是,当网页处于移动端布局时,取消自定义菜单覆盖。
3.2 菜单事件
与菜单绘制类似,我们通过定义实现对象eventHandlers
,并根据键值来区分内部事件和外部事件。
const handleEvent = (id, eventName, event) => { const item = menuItems.find(item => item.eventName === eventName && item.id === id) || navigationItems.find(item => item.eventName === eventName && item.id === id) || { id: id, eventName: eventName }; if (eventHandlers[eventName]) { eventHandlers[eventName](item, globalData.pointerEvent); } else { executeEvent('eventName', item, [event, globalData.pointerEvent]); } };
menuContainer.addEventListener('click', event => { const navigation = event.target.closest('.menuNavigation-Content a'); const menuContent = event.target.closest('.menuLoad-Content span'); const targetElement = navigation || menuContent;
if (targetElement && targetElement.dataset.eventName && targetElement.dataset.id) { handleEvent(targetElement.dataset.id, targetElement.dataset.eventName, event); } });
|
3.3 外部方法
如 1.1 小节所述,在调用外部方法时,程序会尝试从window
对象中读取执行。除此之外,此调用还支持简单的点分链式调用、参数传递和文本替换。对于一些简单的功能,例如希望实现回到上一页,只需要执行window.history.back
,因此在配置文件中的eventName
值里,就可以这样填写。
具体而言,对于传递过来的eventName
值,首先使用正则表达式进行匹配,尝试获取functionPath
和functionArgs
。
const functionMatch = eventName.match(/^([^\(]+)\((.*)\)$/);
let functionPath, functionArgs; if (functionMatch) { functionPath = functionMatch[1].trim(); functionArgs = functionMatch[2].split(',').map(arg => arg.trim().replace(/['"]/g, '')); } else { functionPath = eventName.trim(); functionArgs = []; }
|
另外,对于长度为一的参数,进行如下替换:根据属性值尝试匹配globalData
的键值。例如,如果想要在新标签页中打开链接,可以这样定义:eventName: 'window.open(linkAddress)'
。而对于需要传递字符串的调用,如百度搜索或必应搜索,可以在参数中用##
包裹待替换的内容来标定,例如:
- {eventName: 'window.open("https://www.bing.com/search?q=##selectedText##")'}
|
最后,将functionPath
拆分为数组,逐层访问对象属性,并记录上一层对象,以确保在调用函数时正确设置this
。如果最终解析到的context
是一个函数,则使用apply
方法调用它。
const properties = functionPath.split('.'); let context = window; let parentContext = null;
for (const prop of properties) { parentContext = context; context = context[prop]; if (typeof context === 'undefined') { return; } }
if (typeof context === 'function') { return context.apply(parentContext, [...functionArgs, ...args]); }
|
3.4 实现代码
综上所述,完整的实现内容请参见以下链接中的文件:
四、附录内容
4.1 动态样式
诸如阅读模式和打印模式的功能设计,可以通过修改页面元素样式来实现。例如,在打印网页时,可以通过媒体查询media="print"
引入专用于打印的样式文件。
<link rel="stylesheet" href="/css/print.css" media="print">
|
可以在开发者工具的样式编辑器中,开启模拟 CSS 媒体类型的功能,以调试打印样式。

对于阅读模式,可以通过控制 link 标签的 disabled 属性来决定样式是否生效。
<link id="reading-mode" rel="stylesheet" disabled="true" href="/css/read.css">
<script> const readModeStylesheet = document.getElementById('reading-mode'); readModeStylesheet.disabled = !readModeStylesheet.disabled; </script>
|
4.2 图片粘贴
一个问题:如果剪贴板中有图片,可以将其粘贴到输入框(评论框)中吗?
以下是 Artalk 评论系统中关于图片上传的处理定义:
const onPaste = (evt: ClipboardEvent) => { const files = evt.clipboardData?.files if (files?.length) { evt.preventDefault() uploadFromFileList(files) } }
this.kit.useMounted(() => { this.kit.useUI().$textarea.addEventListener('dragover', onDragover) this.kit.useUI().$textarea.addEventListener('drop', onDrop) this.kit.useUI().$textarea.addEventListener('paste', onPaste) })
|
在浏览器环境中,直接主动触发 paste
事件是不可行的(虽然在 Electron 中可以实现)。但是浏览器允许显示操作来读取剪贴板和处理粘贴事件。恰巧,我们此时有一个由用户主动触发的菜单点击事件。
由于无法修改 Artalk 的图片上传实现,我们需要另辟蹊径:从剪贴板读取内容,将图片文件添加到 DataTransfer
对象中,然后创建并触发一个 ClipboardEvent
,将 DataTransfer
对象中的内容传递给输入框的粘贴事件中。
let imageFiles = []; for (const item of clipboardItems) { for (const type of item.types) { if (type.startsWith('image/')) { const imageBlob = await item.getType(type); const file = new File([imageBlob], 'clipboard-image.png', { type: type }); imageFiles.push(file); } else if (type === 'text/plain') { } } }
for (const file of imageFiles) { const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); const pasteEvent = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true });
pointerEvent.target.dispatchEvent(pasteEvent); }
|