Butterfly自定义右键菜单
Butterfly自定义右键菜单
逛别人博客的时候,发现不少站点的右键菜单都被魔改过,比浏览器原生菜单好看太多了,心里痒痒说干就干。这里记录一下本人这次魔改的全过程,以及途中踩到的几个坑。
代码部分大部分由AI完成,若有谬误,欢迎指正。
参考文档:
先看看要实现什么效果
右键菜单根据选择内容的不同,需要自动切换成不同的形式,有以下几种情况:
通用:前进、后退、刷新、回顶
不选中元素:在空白处直接右键,功能有:随便逛逛(随机文章)、复制地址、深色模式、转为繁体
选中文字:先选中一段文字再右键,功能:复制选中文本,站内搜索,必应搜索
选中按钮/链接:右键选中一个可跳转的按钮或链接,功能:新窗口打开、复制链接地址
选中图片:右键选中一张图片,功能:复制图片,下载图片。
美术上,就采用洪哥的方式,追求简约:白底黑字,深色模式下黑底白字,圆角。鼠标悬浮时浅色模式是黑底白字,深色模式是黄底黑字。
整体思路:不改主题模板
在网上找参考的时候,看到的大部分教程都是这个路子:先在主题的 layout/includes/ 里新建一个 xxx.pug 写菜单的 DOM,然后去 layout.pug 里加一行 !=partial(...) 把它挂上去,再往主题配置里塞两个 CDN 的 css / js 链接。
这个做法能跑,但有个麻烦问题:动了主题的模板文件。以后主题升级,git pull 下来一堆冲突,改过的地方还得一个个找回来,非常痛苦。之前搜索过一些关于Hexo魔改思路的文章,核心原则基本是「能配置就不写样式,能写样式就不改模板」,所以这次我换了个思路:
- 不动主题的任何
.pug文件 - 菜单的 DOM 结构完全由 JS 动态创建,然后
appendChild到<body>上 - 只通过主题自带的
inject配置,注入一个 CSS 和一个 JS
这样主题目录始终是干净的,升级时几乎零成本。整个链路长这样:
配置里就这么几行,写在 _config.butterfly.yml 里:
1 | inject: |
而 rightmenu.css 和 rightmenu.js 放在站点的 source/ 目录下(.css 会被原样复制到 /css/,.js 同理),跟主题彻底解耦。
把DOM交给JS生成
既然不用 pug,那就得用 JS 把结构造出来。为了后面好增删菜单项,把所有菜单项写成了一个配置数组,每一项只要描述“图标 + 文字 + 动作”就行:
1 | var NORMAL_ITEMS = [ |
然后把数组渲染成 HTML 字符串,一次性塞进菜单容器:
1 | function buildGroup(group) { |
这样做的好处是:以后想加个菜单项,只要往数组里加一行,再在 ACTIONS 里补一个同名函数,dom 结构和样式都不用动。
菜单样式
样式部分尽量往简约了做,用 CSS 变量把两套配色抽出来,深色模式只需要覆盖变量即可,不用写两遍规则:
1 | #rightMenu { |
几点小细节:
- 用
position: fixed配合鼠标的clientX / clientY定位,这样页面滚动也不影响; - 层级上遮罩要比菜单低一层(遮罩 2000、菜单 2001),不然遮罩会把菜单盖住,点哪个都点不动;
- 遮罩加
margin: 0 !important,防止主题的全局样式把它挤歪; - 分组之间用
border-top而不是border-bottom画分隔线。因为同一时刻只有一个分组是可见的,其余都是display: none,用border-top才能让分割线始终紧跟在“当前可见的那个分组”上,不会在菜单底部留一条多余的线。
四种形态怎么判断
这是整个功能的核心。在 contextmenu 事件里,根据事件目标往上找,判断用户点在了什么上面,优先级是 图片 > 链接 > 选中文字 > 普通:
1 | var imageEl = target.closest('img') |
判断完只做一件事:把对应的分组显示出来,其余隐藏。
1 | function setGroup(name) { |
然后把鼠标坐标和上下文存起来,供后续的菜单项使用。
菜单项都干了什么
这部分其实没什么技术含量,基本都是在调浏览器 API,简单列一下:
| 菜单项 | 实现方式 |
|---|---|
| 前进 / 后退 / 刷新 | history.back() / history.forward() / location.reload() |
| 回到顶部 | window.scrollTo({ top: 0, behavior: 'smooth' }) |
| 随便逛逛 | 请求 /posts.json 随机挑一篇文章跳转 |
| 复制地址 | navigator.clipboard.writeText(),失败时降级到 execCommand('copy') |
| 深色模式 | 调用主题自己的接口(见下一节) |
| 轉為繁體 | 调用主题自己的翻译函数(见下一节) |
| 复制选中文本 | 读取 window.getSelection() 的结果 |
| 站内搜索 | 打开主题的搜索弹窗并预填关键词 |
| 必应搜索 | 打开 bing.com/search?q=... |
| 新窗口打开 / 复制链接地址 | window.open() / 复制 a.href |
| 复制此图片 / 下载此图片 | fetch 拿 Blob 再写剪贴板 / 生成 <a download> 下载 |
「随便逛逛」需要一份全站文章清单,这个用 Hexo 的生成器就能做出来。在站点根目录新建 scripts/random-post.js,Hexo 会自动加载它:
1 | hexo.extend.generator.register('posts-json', function (locals) { |
这样每次 hexo generate 都会生成一个 /posts.json,前端随机取一条跳过去就行,比去解析归档页面可靠一些。
能复用的部分
主题其实已经把很多东西都准备好了,你只要接上去就行。例如深色模式,直接调主题挂在 window.btf 上的方法:
1 | var willChange = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark' |
别忘了还要通知主题内部其他依赖主题色切换的组件(比如 Mermaid 图表要重绘),不然切了深色模式它们还是一副旧面孔:
1 | var themeChange = (window.globalFn || {}).themeChange |
繁简切换同理,主题的 tw_cn.js 把函数挂在了 window.translateFn 上,直接调 translatePage() 即可,其余的都不用自己操心。
踩坑记录
前面写得挺顺,实际上真正花时间的都在这里。
坑一:菜单跑到屏幕外
一开始我是在脚本加载时就把菜单的宽高量好存起来:
1 | let rmWidth = $('#rightMenu').width() |
然后判断“右边放不下就翻到左边”。听起来没毛病,但实际用起来菜单还是会溢出屏幕。
最后用AI解决了,原因很朴素:脚本加载的那一刻,菜单还是 display: none,此时量出来的宽高是 0,于是越界判断永远不成立。解决办法是先显示、再测量、最后定位,并且用原生的 offsetWidth / offsetHeight 实时测量:
1 | function showAt(clientX, clientY) { |
这里用 visibility: hidden 而不是 display: none 来做“隐形测量”,是因为 visibility 不改变布局,量出来的尺寸是可靠的。
坑二:输入框里没法右键粘贴
因为我们在 contextmenu 里无条件 preventDefault() 阻止了浏览器原生菜单,结果就是在输入框、文本域里右键也弹不出“粘贴”了。所以必须留个后门,遇到输入区域就放行:
1 | if (target.closest('input, textarea, [contenteditable="true"]')) return |
坑三:window.oncontextmenu 覆盖问题
参考文章里是用 window.oncontextmenu = function () {...} 这种方式注册的。这样写等于直接覆盖了这个属性,如果页面里还有别的脚本也监听了右键,就会被顶掉。
改用 addEventListener 就没这个问题:
1 | document.addEventListener('contextmenu', onContextMenu) |
坑四:window.GLOBAL_CONFIG读取不到: undefined
主题会在 <head> 里注入一大坨配置,我在菜单里需要读它的 root 和 translate。于是想当然地写了:
1 | var config = window.GLOBAL_CONFIG || {} |
结果配置永远读不到,繁简切换功能的状态不更新,切换到繁体之后,菜单上仍然显示“轉為繁體”。
翻开源码发现主题是这么写的:
1 | const GLOBAL_CONFIG = { ... } |
注意是 const,不是 window.GLOBAL_CONFIG = ...。
在传统的 <script> 标签里,用 const / let 声明的顶层变量,只会进入全局词法环境(其他脚本可以用裸标识符直接访问它,主题自己也确实是这么用的),但它不会成为 window 对象的一个属性。
所以下面两种情况的结果完全不同:
1 | GLOBAL_CONFIG.translate // ✅ 能拿到 |
改成裸标识符访问就好了,顺手加个保护:
1 | function globalConfig() { |
坑五:站内搜索功能无反应
这个也折腾了一阵。「站内搜索」功能本意是复用主题自带的搜索弹窗,但一开始写的代码完全没反应,最后还是用AI解决的,查下来有三个问题:
- 选择器错了:主题搜索框的
input根本没有 id,真实结构是#local-search .local-search-input input; - 点错了元素:主题把点击事件绑在内层的 span 上(
#search-button > .search),直接对父级#search-button调.click()是不会触发的,事件不会向下传递; - 懒加载竞态:这里我也不太懂,意思是本地检索数据是打开弹窗之后才去下载的,而主题的输入处理里有一句
if (!localSearch.isfetched) return。也就是说数据还没到位时你填进去的关键词会被静默忽略,用户看到的就是“什么都没搜到”。
前两个改选择器就行,第三个问题通过监听主题加载完成的事件,等数据就绪后再补一次:
1 | trigger.click() |
顺便提一句,butterfly的搜索功能要在
_config.butterfly.yml里设置search.use字段,默认是空的。
最终改动文件
整个过程新增或改动的文件如下,主题文件没有改动:
| 文件 | 作用 |
|---|---|
source/css/rightmenu.css |
菜单样式(浅色 / 深色 / 悬浮态 / 轻提示) |
source/js/rightmenu.js |
菜单结构与全部逻辑 |
scripts/random-post.js |
生成 /posts.json,供「随便逛逛」使用 |
_config.butterfly.yml |
用 inject 引入最上面两个文件 |
_config.yml |
补上站内搜索的 search 配置段 |
以后主题升级,只要这三处文件还在,功能就不会丢;实在冲突了,删掉 inject 那两行也能一键回退。




