--FPS

自定义右键菜单:配置驱动、上下文感知,和三个隐蔽的坑

2857 字
14 分钟
自定义右键菜单:配置驱动、上下文感知,和三个隐蔽的坑
自定义右键菜单:配置驱动、上下文感知,和三个隐蔽的坑
智能摘要
AI 生成摘要

起因#

刷别人博客的时候见过几回自定义右键菜单:右键出来的不是浏览器原版那一坨,而是主题自己画的一列选项,前进后退、复制链接、进阅读模式一应俱全。这东西不算必要功能,但点开的那一下很有「这站被人认真打理过」的感觉。决定自己也整一个。

先把想要的形态列清楚:

  1. 所有菜单项走配置。加一项、删一项、调顺序,改配置文件就行,不碰组件
  2. 右键在哪,菜单不一样。选中文本时出「复制、搜索」,链接上出「新窗口打开、复制地址」,图片上出「打开图片、复制图片地址」
  3. 页面能力不同,菜单也不一样。文章页才有「回到评论」,装了音乐播放器才出播放控制,能进阅读模式的页面才给「阅读模式」
  4. Ctrl+右键保留原版菜单。不赶尽杀绝,偶尔还得用原版的「检查」
  5. 只在桌面端生效。窄屏和触屏没这个交互习惯,小于某个宽度直接放行

清单看着长,落到实现上其实就一句话:菜单内容是动态的,但菜单本身必须是声明式的。

思路:一份配置说了算#

组件被压得很薄——配置决定「有什么」,运行时决定「显示什么、放哪」,CSS 只负责「藏」和「放」。

配置长这样:

src/config/contextMenuConfig.ts
export const contextMenuConfig: ContextMenuConfig = {
enable: true,
minWidth: 1024,
// 在输入框/文本域/可编辑元素内右键时展示原版菜单
showNativeInEditable: true,
// 搜索选中内容用的搜索引擎,{query} 会被替换成选中文本
searchEngine: "https://www.baidu.com/s?wd={query}",
contextItems: {
copySelection: true, // 有选中文本:复制
searchSelection: true, // 有选中文本:搜索
openLink: true, // 右键链接:新窗口打开
copyLink: true, // 右键链接:复制地址
openImage: true, // 右键图片:打开图片
copyImage: true, // 右键图片:复制地址
},
items: [
{ action: "history-back" },
{ action: "history-forward" },
{ action: "reload", divider: true },
{ action: "music-toggle-play" },
{ action: "music-prev" },
{ action: "music-next" },
{ action: "music-volume" },
{ action: "toggle-theme", divider: true },
{ action: "reading-mode" },
{ action: "back-to-comment" },
{ action: "back-to-top" },
{ action: "copy-url" },
],
};

items 里每项是一个内置 action,divider 控制分组分隔线。想加外链或自定义行为,用 action: "custom" 配 link 或者一段函数体,图标不指定时组件会按 item.icon ?? 默认图标 兜底。类型在 src/types/contextMenuConfig.ts 里收着,配置写错了构建期就能报出来。

早期版本试过直接在模板里堆菜单节点,结果改个顺序要动模板,加一项要动模板,上下文显隐逻辑还散落在各处。收进配置之后,这些痛苦全消失了——这也是我坚持「配置驱动」最大的理由:改配置的心智负担和改组件完全不是一个量级。

拦截:先想清楚什么时候「不管」#

写自定义菜单,最先要想清楚的不是怎么接管,而是什么时候不管。放行条件比拦截条件好写——前者每条都是独立的,后者要把所有反例串成一长串否定。

我的放行条件就三条:

src/components/features/ContextMenu.astro
document.addEventListener("contextmenu", (e) => {
if (e.ctrlKey) return; // Ctrl+右键:放行原版菜单
if (window.innerWidth < minWidth) return; // 窄屏:没这个交互
const target = e.target instanceof Element ? e.target : null;
const isFormField =
!!target?.closest("input, textarea, select") ||
(target instanceof HTMLElement && target.isContentEditable);
if (isFormField && showNativeInEditable) return; // 输入框里:原版菜单更好用
e.preventDefault();
});

流程画出来是这样:

是

否

是

否

是

是

否

否

contextmenu 事件

按住 Ctrl?

放行:原版菜单

视口窄于配置宽度?

焦点在输入框等可编辑区?

showNativeInEditable?

preventDefault 接管

探测上下文 → 挂标记类

隐身测量 → 定位

显示

是

否

是

否

是

是

否

否

contextmenu 事件

按住 Ctrl?

放行:原版菜单

视口窄于配置宽度?

焦点在输入框等可编辑区?

showNativeInEditable?

preventDefault 接管

探测上下文 → 挂标记类

隐身测量 → 定位

显示

另外两个工程细节:绑定前先检查 window.__fireflyContextMenu 防止重复挂监听(Astro 页面脚本在某些跳转场景会重新执行,不设防会越绑越多);关菜单的途径比开菜单多得多——点遮罩、Esc、窗口失焦、滚动、resize、路由跳转全都要收尾,其中滚动监听用 capture 加 passive 就够了,反正目的只是把菜单收起来。

这段代码里埋着本篇坑二的祸根,先按下不表。

上下文感知:两组标记类#

探测上下文,说穿了就是把一堆布尔判断折算成根元素上的类名:

src/components/features/ContextMenu.astro
// 右键位置:点在哪
menu.classList.toggle("show-selection", hasSelection);
menu.classList.toggle("show-link", !!link);
menu.classList.toggle("show-image", !!img);
// 页面能力:这页有什么
menu.classList.toggle("ctx-has-music", typeof music()?.getState === "function");
menu.classList.toggle("ctx-has-comments", !!document.getElementById("post-comments"));
menu.classList.toggle("ctx-has-reading", isReadingModeAvailable());

两组前缀语义不同:show-* 描述「右键点在哪」,每次打开都重新探测;ctx-has-* 描述「这个页面有什么能力」。CSS 那边配合很机械——扩展项默认藏起来,标记类出现才放行:

src/components/features/ContextMenu.astro(样式部分)
#context-menu
.ctx-need-selection
display: none
&.show-selection
.ctx-need-selection
display: flex

注意第三行的 &,它是坑一的主角,后面细说。

能力探测里有几个值得展开的点:

音乐播放器是 Svelte island,把实例挂到 window.__fireflyMusic 上。但 island 水合有时机问题——页面刚开就右键,这个全局对象可能还不存在;就算存在了,也得确认它真能用。所以探测写成 typeof getState === "function" 而不是简单判存在:对象在、方法还没挂上的窗口期里,单纯判存在会误报。

评论区探测最朴素:document.getElementById("post-comments") 找得到节点就有评论。因为评论区只渲染在文章页,这一行天然把「文章页」的语义带上了,不用再判断路径。

阅读模式的判断和站内阅读模式按钮共用同一个谓词:路径在 /posts/ 下且视口够宽。两处逻辑一个标准,避免「按钮说能进、菜单里却没有」的尴尬。

定位:先隐身,量完再现身#

弹出位置看着五分钟搞定,实际有个经典陷阱:菜单贴近屏幕右缘时,音量条那组二级菜单会探出屏幕。等渲染出来再发现溢出,视觉上就是闪一下。

做法是显示之前先隐身量尺寸:

src/components/features/ContextMenu.astro
const SUBMENU_SPACE = 240; // 给二级菜单留的空间
menu.classList.remove("hidden", "ctx-flip-left");
menu.style.visibility = "hidden";
const rect = menu.getBoundingClientRect();
const flipLeft = x + rect.width + SUBMENU_SPACE > vw;
if (flipLeft) menu.classList.add("ctx-flip-left");
menu.style.visibility = "";

关键在用 visibility: hidden 而不是 display: none——前者占据布局、能量出真实尺寸,后者量出来全是 0。量完该翻转就加 ctx-flip-left 让二级菜单朝左开,最后再去掉隐身。整个过程在同一帧内完成,肉眼看不到闪。

明暗适配#

这部分反而最省心:颜色全部走站内 CSS 变量,var(--card-bg)、var(--btn-regular-bg-hover) 这类,主题切换时变量一变菜单自动跟上。唯一手写的是深色模式下的文字颜色,站内惯例是 :global(.dark) 里配一个半透明白,照抄即可。

踩坑清单#

下面按踩到的顺序讲三个坑。共同点是:每一个的现象都在暗示「代码是对的」,而真凶全在别处。

坑一:Stylus 嵌套少写一个 &,样式全灭#

现象:文章页右键,音乐控制和阅读模式死活不出现。DevTools 里看元素,ctx-has-music 标记类明明白白挂在根元素上,对应的 CSS 规则也能搜到——就是不生效。

当时的排查动作是拿源码里的选择器去控制台对元素做 matches() 验证,结果永远是 false,一度把我带偏到怀疑类名拼错。后来才反应过来:Astro 的 scoped style 编译时会往每一段选择器里塞 data-astro-cid-xxx 属性,源码里的 #context-menu .ctx-has-music 和运行时真实的选择器根本不是同一串字符。调样式问题,看编译产物,别看源码。

编译产物(简化了无关细节)长这样:

编译产物:坏的
#context-menu[data-astro-cid-mdguodk3] .ctx-has-music .ctx-need-music {
display: flex;
}

而实际的 DOM 结构是这样的:

简化后的结构
<div id="context-menu" class="ctx-has-music" data-astro-cid-mdguodk3>
<div class="ctx-need-music">音量</div>
</div>

看出问题了吗?坏的选择器要求 .ctx-has-music 是 #context-menu 的后代,可这个类挂在根元素自己身上,后代里根本不存在——这条规则永远匹配不到任何元素,于是菜单项一直保持 display: none。

根源在 Stylus 嵌套少写了一个 &:

错:第一层没有 &
#context-menu
.ctx-has-music
.ctx-need-music
display: flex
对:&. 连写在根元素上
#context-menu
&.ctx-has-music
.ctx-need-music
display: flex

一字之差,编译产物从「空格分隔找后代」变成「连写找自身」:

编译产物:对的
#context-menu[data-astro-cid-mdguodk3].ctx-has-music .ctx-need-music {
display: flex;
}
Tip

Astro/Stylus 里凡是「状态类挂在容器根上、控制后代显隐」的写法,嵌套第一层必须 & 开头。写完别信源码,去编译产物里确认一眼。

坑二:布尔守卫写反了#

showNativeInEditable 这个配置,本意是「输入框里右键用原版菜单」。第一版代码:

src/components/features/ContextMenu.astro
if (isFormField && !showNativeInEditable) return; // [!code --]
if (isFormField && showNativeInEditable) return; // [!code ++]

单看每一半都没错:isFormField 判断是 Copy 来的没毛病,showNativeInEditable 字段名也没拼错。但把条件念出来就露馅了——「在输入框里,且 配置说要用原版菜单,所以放行」,念成第一版就是「配置开着,反而拦截」,语义整个颠倒。

这个 bug 的刁钻之处在于:默认配置下功能「看起来正常」,只是输入框里也弹自定义菜单;要不是特意去输入框里右键试了一把,可能很久都发现不了。

后来养成的自查习惯:布尔条件写完念一遍。比如「如果 A 且非 B,放行」翻译成人话是「B 开着反而拦着」——一念就穿帮。

坑三:dev 里好好的,preview 里菜单缺项#

最玄学的一个。源码翻来覆去看没问题,dev 服务里右键 12 项全出;切到 pnpm preview,音乐控制和阅读模式没了,连样式都对不上。

第一反应是「preview 模式行为不一样」——还真不是。真相是:

astro preview 只伺服上次 astro build 产出的 dist/,它自己不编译任何东西。而那份 dist/ 是坑一修复之前构建的,里面躺着的还是缺 & 的坏选择器。dev 实时编译所以一直是对的,preview 开的是罐头所以一直是旧的。

验证方法也简单:直接翻 dist/_astro/*.css,拿「连写」的正确形态去搜,搜不到;拿「后代链」的坏形态一搜一个准。罐头内容和菜谱对不上,问题就不在菜谱。

Warning

排查样式问题前先问自己一句:我看到的是哪份代码? dev 反映源码,preview 反映上次构建,部署环境反映更早的构建。三者不同步时,现象会互相矛盾,但每一份都如实反映了它对应的那份代码。改完记得重新 build,别让旧产物陪跑。

写在最后#

右键菜单是典型的「平时没人看,写起来细节不少」的功能。回头看,思路层面就三件事:放行条件先于拦截条件想清楚、状态折算成标记类交给 CSS、量完尺寸再显示。剩下的坑大多出在源码和运行时之间的那些层上——Stylus 会编译选择器,Astro 会改写选择器,preview 伺服的是历史产物。每一层都在帮忙,也都在让你离真相远一步。

主题完整代码在这里:

kmoretti
/
firefly
🍀Firefly, fresh and aesthetic Astro blog theme template.
—
—
MIT
Astro

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
自定义右键菜单:配置驱动、上下文感知,和三个隐蔽的坑
https://b.kemeow.top/posts/custom-context-menu/
作者
克喵Moretti
发布于
2026-09-07
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
给博客动态页接 Ech0:一条代理链路和几个绕不开的坑
技术动态页的数据源从 Memos 换成自部署的 Ech0。这篇记录整条数据链路:为什么走服务端同源代理而不是前端直连、echo/query 的分页怎么收尾、Markdown 和扩展卡片分别在哪一层渲染,以及实际踩到的几个坑——服务端 pageSize 钳位、时间戳是秒不是毫秒、匿名请求拿不到私密说说、推文嵌入被墙的降级方案。
2
AI 文章摘要:从生成脚本到打字机组件的折腾记录
技术给博客文章加 AI 摘要的记录:本地脚本调硅基流动 Qwen3-8B 生成 50~100 字摘要写进 frontmatter,前端用 Svelte 5 写了个带打字机动画和窄屏折叠的组件。文章按「数据从哪来、前端怎么展示」两条线讲,顺带记录了折叠裁切、dev 缓存、离线图标几个坑。
3
引入 swpp:先定规则,再谈缓存,以及集成路上踩过的几个坑
技术给博客接入了 swpp——一个规则驱动、把「精确控制缓存」当主业、把离线当副产品的 Service Worker 方案。文章先讲清楚它那套有点反直觉的缓存协议(false 不缓存、-1 永久缓存、版本号驱动失效),再展示双端 matchCacheRule 与构建编排,最后复盘六个集成期真实踩到的坑:saveFiles 撞上已存在文件、HTML 注入要求结构完整的 head、runOnBrowser 的序列化函数不能碰闭包、永久缓存的名不副实、HTTP 缓存头不配合版本检查失灵,以及 Service Worker 永远慢半拍的验证节奏。
4
国内外分流实践:Cloudflare + EdgeOne 双端部署的踩坑记录
部署给博客做国内外分流,把国内用户 302 到 EdgeOne 子域。文章记录了双端部署的完整思路,以及备案限制、CNAME、重定向规则、强制 HTTPS 这一路上的坑。
随机文章随机推荐

评论区

Profile Image of the Author
克喵Moretti
每一段旅行,都有终点。
公告
欢迎来到我的博客!有什么问题可以 CTRL + F5 强制刷新一下。
欢迎
分类
标签
最新动态
站点统计
文章
5
分类
2
标签
20
总字数
11,674
运行时长
0 天
最后活动
0 天前
统计
总浏览量
-
访问数
-
游客数
-
今日浏览
-
今日访问
-
正在浏览
-
站点信息
构建平台
GitHub Actions
博客版本
KeMiaoBlog v0.0.3
文章许可
CC BY-NC-SA 4.0
文章目录