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
2
3
4
5
inject:
head:
- <link rel="stylesheet" href="/css/rightmenu.css">
bottom:
- <script src="/js/rightmenu.js"></script>

  而 rightmenu.cssrightmenu.js 放在站点的 source/ 目录下(.css 会被原样复制到 /css/.js 同理),跟主题彻底解耦。

把DOM交给JS生成

  既然不用 pug,那就得用 JS 把结构造出来。为了后面好增删菜单项,把所有菜单项写成了一个配置数组,每一项只要描述“图标 + 文字 + 动作”就行:

1
2
3
4
5
6
var NORMAL_ITEMS = [
{ action: 'random', icon: 'fa-solid fa-shuffle', text: '随便逛逛' },
{ action: 'copy-url', icon: 'fa-solid fa-arrow-up-right-from-square', text: '复制地址' },
{ action: 'darkmode', icon: 'fa-solid fa-moon', text: '深色模式' },
{ action: 'translate', icon: 'fa-solid fa-language', text: '轉為繁體' }
]

  然后把数组渲染成 HTML 字符串,一次性塞进菜单容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
function buildGroup(group) {
var html = '<div class="rcm-group' + (group.nav ? ' rcm-nav' : '') +
'" data-group="' + group.name + '">'

group.items.forEach(function (item) {
html += '<div class="rcm-item" data-action="' + item.action + '">'
html += '<i class="' + item.icon + '"></i>'
if (item.text) html += '<span>' + item.text + '</span>'
html += '</div>'
})

return html + '</div>'
}

  这样做的好处是:以后想加个菜单项,只要往数组里加一行,再在 ACTIONS 里补一个同名函数,dom 结构和样式都不用动。

菜单样式

  样式部分尽量往简约了做,用 CSS 变量把两套配色抽出来,深色模式只需要覆盖变量即可,不用写两遍规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#rightMenu {
--rcm-bg: #ffffff;
--rcm-text: #1f1f1f;
--rcm-line: rgba(0, 0, 0, 0.07);
--rcm-hover-bg: #1a1a1a;
--rcm-hover-text: #ffffff;

position: fixed;
z-index: 2001;
display: none;
width: 200px;
border: 1px solid var(--rcm-border);
border-radius: 12px;
background: var(--rcm-bg);
color: var(--rcm-text);
}

html[data-theme='dark'] #rightMenu {
--rcm-bg: #0d0d0d;
--rcm-hover-bg: #f5d94a;
--rcm-hover-text: #111111;
}

  几点小细节:

  • position: fixed 配合鼠标的 clientX / clientY 定位,这样页面滚动也不影响;
  • 层级上遮罩要比菜单低一层(遮罩 2000、菜单 2001),不然遮罩会把菜单盖住,点哪个都点不动;
  • 遮罩加 margin: 0 !important,防止主题的全局样式把它挤歪;
  • 分组之间用 border-top 而不是 border-bottom 画分隔线。因为同一时刻只有一个分组是可见的,其余都是 display: none,用 border-top 才能让分割线始终紧跟在“当前可见的那个分组”上,不会在菜单底部留一条多余的线。

四种形态怎么判断

  这是整个功能的核心。在 contextmenu 事件里,根据事件目标往上找,判断用户点在了什么上面,优先级是 图片 > 链接 > 选中文字 > 普通:

1
2
3
4
5
6
7
var imageEl = target.closest('img')
var linkEl = target.closest('a[href]')

var group = 'normal'
if (imageEl) group = 'image'
else if (linkEl) group = 'link'
else if (selection.trim()) group = 'text'

  判断完只做一件事:把对应的分组显示出来,其余隐藏。

1
2
3
4
5
6
function setGroup(name) {
CONTEXT_GROUPS.forEach(function (g) {
var el = menu.querySelector('[data-group="' + g + '"]')
if (el) el.style.display = g === name ? '' : 'none'
})
}

  然后把鼠标坐标和上下文存起来,供后续的菜单项使用。

菜单项都干了什么

  这部分其实没什么技术含量,基本都是在调浏览器 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
2
3
4
5
6
7
8
9
hexo.extend.generator.register('posts-json', function (locals) {
const paths = locals.posts
.sort('-date')
.toArray()
.filter(post => post.published !== false && post.path)
.map(post => String(post.path).replace(/index\.html$/, ''))

return { path: 'posts.json', data: JSON.stringify(paths) }
})

  这样每次 hexo generate 都会生成一个 /posts.json,前端随机取一条跳过去就行,比去解析归档页面可靠一些。

能复用的部分

  主题其实已经把很多东西都准备好了,你只要接上去就行。例如深色模式,直接调主题挂在 window.btf 上的方法:

1
2
3
4
var willChange = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'

willChange === 'dark' ? btf.activateDarkMode() : btf.activateLightMode()
btf.saveToLocal.set('theme', willChange, 2)

  别忘了还要通知主题内部其他依赖主题色切换的组件(比如 Mermaid 图表要重绘),不然切了深色模式它们还是一副旧面孔:

1
2
3
4
5
6
var themeChange = (window.globalFn || {}).themeChange
if (themeChange) {
Object.keys(themeChange).forEach(function (key) {
if (typeof themeChange[key] === 'function') themeChange[key](willChange)
})
}

繁简切换同理,主题的 tw_cn.js 把函数挂在了 window.translateFn 上,直接调 translatePage() 即可,其余的都不用自己操心。

踩坑记录

  前面写得挺顺,实际上真正花时间的都在这里。

坑一:菜单跑到屏幕外

  一开始我是在脚本加载时就把菜单的宽高量好存起来:

1
2
let rmWidth = $('#rightMenu').width()
let rmHeight = $('#rightMenu').height()

  然后判断“右边放不下就翻到左边”。听起来没毛病,但实际用起来菜单还是会溢出屏幕。

  最后用AI解决了,原因很朴素:脚本加载的那一刻,菜单还是 display: none,此时量出来的宽高是 0,于是越界判断永远不成立。解决办法是先显示、再测量、最后定位,并且用原生的 offsetWidth / offsetHeight 实时测量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function showAt(clientX, clientY) {
menu.style.visibility = 'hidden'
menu.style.display = 'block' // 先让它出现在布局里

var w = menu.offsetWidth // 这个时候量才是准的
var h = menu.offsetHeight

var x = clientX + GAP
var y = clientY + GAP

if (x + w > vw - MARGIN) x = clientX - w - GAP // 右边放不下 → 翻到左边
if (y + h > vh - MARGIN) y = clientY - h - GAP // 下面放不下 → 翻到上面

x = Math.min(Math.max(MARGIN, x), vw - w - MARGIN) // 再夹一次,兜底
y = Math.min(Math.max(MARGIN, y), vh - h - MARGIN)

menu.style.left = x + 'px'
menu.style.top = y + 'px'
menu.style.visibility = ''
}

  这里用 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> 里注入一大坨配置,我在菜单里需要读它的 roottranslate。于是想当然地写了:

1
var config = window.GLOBAL_CONFIG || {}

  结果配置永远读不到,繁简切换功能的状态不更新,切换到繁体之后,菜单上仍然显示“轉為繁體”。

  翻开源码发现主题是这么写的:

1
const GLOBAL_CONFIG = { ... }

  注意是 const,不是 window.GLOBAL_CONFIG = ...

  在传统的 <script> 标签里,用 const / let 声明的顶层变量,只会进入全局词法环境(其他脚本可以用裸标识符直接访问它,主题自己也确实是这么用的),但它不会成为 window 对象的一个属性。

  所以下面两种情况的结果完全不同:

1
2
GLOBAL_CONFIG.translate        // ✅ 能拿到
window.GLOBAL_CONFIG.translate // ❌ undefined

  改成裸标识符访问就好了,顺手加个保护:

1
2
3
4
5
6
7
function globalConfig() {
try {
return (typeof GLOBAL_CONFIG !== 'undefined' && GLOBAL_CONFIG) || {}
} catch (err) {
return {}
}
}

坑五:站内搜索功能无反应

  这个也折腾了一阵。「站内搜索」功能本意是复用主题自带的搜索弹窗,但一开始写的代码完全没反应,最后还是用AI解决的,查下来有三个问题:

  • 选择器错了:主题搜索框的 input 根本没有 id,真实结构是 #local-search .local-search-input input
  • 点错了元素:主题把点击事件绑在内层的 span 上(#search-button > .search),直接对父级 #search-button.click() 是不会触发的,事件不会向下传递;
  • 懒加载竞态:这里我也不太懂,意思是本地检索数据是打开弹窗之后才去下载的,而主题的输入处理里有一句 if (!localSearch.isfetched) return。也就是说数据还没到位时你填进去的关键词会被静默忽略,用户看到的就是“什么都没搜到”。

  前两个改选择器就行,第三个问题通过监听主题加载完成的事件,等数据就绪后再补一次:

1
2
3
4
5
6
7
8
9
trigger.click()

pendingSearchText = text
setTimeout(applyPendingSearch, 150)

// 本地搜索数据是懒加载的,加载完成后必须再触发一次
window.addEventListener('search:loaded', function () {
setTimeout(applyPendingSearch, 0)
})

  顺便提一句,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 那两行也能一键回退。