image

一、前缘

Volantis 的右键大体来说是我维护的,它的由来其实很有趣:当时是做了全局鼠标形状替换,但是在右键菜单展现时图标的修改被打回了原形,所以就萌生了替换掉右键菜单的想法。第一版的右键是仿照主题的菜单实现的,样式直接引用自菜单,功能上只是单纯的链接跳转。

后来 xaoxuu 觉得这个不错,就添加了顶部导航栏、音乐控制模块等,将自定义右键抽成了独立的功能,如此算是有了一个良好的开端;接着就是一些功能上的增减,主要围绕剪切板和内置事件展开;再后来 Volantis 开始去 JQ 化,右键也就跟着摘掉依赖,如此迭代后,才形成了目前主题内置的右键状态。

此时的右键菜单,所有的功能类按钮的事件实现全部内置,用户在配置方面只能做到新建一些静态链接、调调顺序罢了,而在代码实现上,逻辑实现提示挺弯弯绕的,灵活性极差。功能少自然配置上也就简单一些,这算是另类的优点吧。基本的,目前无法手动添加事件执行类的菜单项目,同时内部的菜单项是写死在页面中的,如果想要对这部分进行修改只能去改源码。另外也无法自行响应右键在不同状态下打开时的行为,所以主要的改进有两点:菜单动态生成、支持自定义脚本。

二、设计

后来也看到了其他博客的右键实现,比如糖果屋、Heo 博客的,不过理想中最完善的自定义右键是 Code-Server,奈何这类大项目真读起来还是太费时间的就没有太过研究,不过重写右键的想法确实被勾起了。原先在右键的功能上的实现有过一些积累,见文章 “前端页面的自定义右键与剪切板操作实现”,一并可以拿来参考,接着就是重新设计右键菜单的实现了。

配置文件

重构是从配置文件开始的,由于要实现动态菜单,我需要一个统一的菜单对象:

- {id: '', name: '', icon: '', link: '', event: '', group: ''}

id, name, icon 作为基本的属性就不多提了,用来匹配菜单名称和图标;对于链接性质的菜单项,直接将地址填写进 link 中,使用时渲染成 a 链接即可;而对于事件形式的菜单项,写入到 event 里,这里就需要区分内置事件和用户自行输入的事件,所以维护一个内置事件列表,如果输入内容与内置事件匹配,调用右键菜单的内置实现,其余的当作自定义脚本执行;而 group 主要用于区分响应行为上,比如只在图片上右键时才显示菜单项,同样维护一个内置组。

一个组中可以拥有单个或多个菜单项,内置组根据右键时的状态确定组内菜单是否显示。在不考虑二级菜单的情况下,为了减少整个菜单的数量(长度),含有 event 的组显示时隐藏掉含有 link 项的菜单:基本链接型的菜单都是用户添加,功能型的菜单动态出现。

启用及排序

为了进一步区分,菜单项分为 pluginsmenus 两大类,在使用上通过 plugins/menus.[组名] 的方式控制菜单的启用及排序,例如:

rightmenus:
order:
- plugins.elementCheck
- hr
- menus.links
- menus.links2
plugins:
elementCheck
- {}
- hr
- {}
menus:
links:
- {}
links2: {}

这里利用 order 控制具体加载的组,同时对于一些特殊的功能,如分割线(hr)和音乐控制器(music),也是在这个属性中配置的,属性项目的前后顺序代表了菜单加载的前后顺序。

功能示例

预置菜单功能

三、实现

有了数据模型,接下来就是对它的功能实现了

页面渲染

读取 order 组的内容遍历加载,由于这里实际填写的是组名,不是具体的内容,所以还需要根据键值去找具体的内容,读取方式类似于 dataGet(rightmenus, 'plugins.elementCheck')

用到了可选链操作符,所以需要 Ndoe14+ 环境
let dataGet = (data, keyStr) => {
const keys = keyStr.split('.');
let currentData = data;
for (const key of keys) {
currentData = currentData?.[key];
}
return currentData;
}

拿到菜单对象后,就是根据其中内容解析成对应元素,以 eventlink 的值做主要区分,link 属性不为空 写成 a 标签,反之就当事件型的菜单。除了通用的菜单项,额外需要特殊对待的就是独立处理就行了:好在不多,也就是横向的导航栏和音乐控制模块。

接下来就是这些菜单项的事件响应部分,主题用一个公共的配置对象,将配置文件中的 rightmenus 以 JSON 对象的方式存储在程序中,在 JS 代码里读取这个对象就可以拿到相应内容。所以最开始是打算只判断 event 中的值是否为内置事件列表中的,如果不是就简单的使用 eval() 或者 new Function(),套个 try catch 直接执行得了,可是后来主题增加了内容安全策略(CSP),这两个方法都在禁用列表中,所以只能妥协,先行将 event 的内容封装成函数写到页面中,JS 文件中再根据相应的属性名调用相关函数,如此才不算是动态插入的函数,这一部分放到了 RightMenusFunction 对象中。

<% rightMenu.rederFunction = item => { %> 
<% if (!!item && !!item['event'] && item['group'] !== 'navigation') { %>
<% if (rightMenu.defaultEvent.some(value => { return value === item['event'] })) { %>
<!-- RightMenusFunction['<%- item.id %>'] = (fun) => {fun()} -->
<% } else if (item['group'] === 'seletctText') { %>
RightMenusFunction['<%- item.id %>'] = (__text__) => {<%- item.event %>}
<% } else if (item['group'] === 'elementCheck' || item['group'] === 'elementImage') { %>
RightMenusFunction['<%- item.id %>'] = (__link__) => {<%- item.event %>}
<% } else { %>
RightMenusFunction['<%- item.id %>'] = () => {<%- item.event %>}
<% } %>
<% } %>
<% } %>

上面代码基础的一般性的执行的是 RightMenusFunction['<%- item.id %>'] = () => {<%- item.event %>} 这一句,利用菜单项的 id 调用它的具体实现,其它的如 seletctText/elementCheck 的特殊处理是因为得传递相关参数,例如事件输入 window.open(__link__),需要将 __link__ 传递成真实的值。

功能实现

在 JS 环境中,我可以拿到 volantis.GLOBAL_CONFIG.plugins.rightmenus, RightMenusFunction 两个相关对象,前者提供了配置文件中的设定,后者是一个函数封装。

元素位置控制

在正式决定哪些菜单项显示之前,得先让整个右键菜单显示出来(并且在正确的位置上),右键菜单 rightmenu-wrapper 默认隐藏、绝对定位,在不设置 top, left 属性前,将其设置 block 就能获取到菜单的宽高,与右键时的位置和屏幕显示宽高做对比,就能决定显示位置了。

let mouseClientX = event.clientX;
let mouseClientY = event.clientY;
let screenWidth = document.documentElement.clientWidth || document.body.clientWidth;
let screenHeight = document.documentElement.clientHeight || document.body.clientHeight;
_rightMenuWrapper.style.display = 'block';
let menuWidth = _rightMenuContent.offsetWidth;
let menuHeight = _rightMenuContent.offsetHeight;
let showLeft = mouseClientX + menuWidth > screenWidth
? mouseClientX - menuWidth + 10 : mouseClientX;
let showTop = mouseClientY + menuHeight > screenHeight
? mouseClientY - menuHeight + 10 : mouseClientY;
showTop = mouseClientY + menuHeight > screenHeight
&& showTop < menuHeight && mouseClientY < menuHeight
? showTop + (screenHeight - menuHeight - showTop - 10) : showTop;
_rightMenuWrapper.style.left = `${showLeft}px`;
_rightMenuWrapper.style.top = `${showTop}px`;

元素状态判断

通过对右键事前的内容进行处理,判断当前是在什么元素上打开右键菜单,处于什么状态。

globalData.selectText = window.getSelection().toString();
// 判断是否为输入框
if (event.target.tagName.toLowerCase() === 'input'
|| event.target.tagName.toLowerCase() === 'textarea') {
//...
}
// 判断是否包含链接
if (!!event.target.href && RightMenus.urlRegx.test(event.target.href)) {
//...
}
// 判断是否包含媒体链接
if (!!event.target.currentSrc && RightMenus.urlRegx.test(event.target.currentSrc)) {
globalData.isMediaLink = true;
globalData.mediaLinkUrl = event.target.currentSrc;
}
// 判断是否为图片地址
if (globalData.isMediaLink && RightMenus.imgRegx.test(globalData.mediaLinkUrl)) {
//...
}
// 判断是否为文章页面
if (!!(document.querySelector('#post.article') || null)) {
//...
}

菜单项显示控制

在确定了右键状态后就可以着手控制元素显隐了,利用 document.querySelectorAll 获取所有的菜单项,遍历菜单根据状态设置 display 属性。此时之前菜单对象的 group 属性就派上用场了,通过对内置组匹配,成组的决定菜单项的显示。

菜单项的动态显隐带来了一个问题,本来用于间隔的分割线(hr)可能在实际显示上相邻了,如此还需要重新处理下这个:主要思路为默认分割线需要隐藏,遍历菜单,如果两个分割线之间至少存在一个可以显示的菜单,那么分割线就能显示,反之隐藏。

let elementHrItem = { item: null, hide: true };
_rightMenuListWithHr.forEach((item) => {
if (item.nodeName === "HR") {
item.style.display = 'block';
if (!elementHrItem.item) {
elementHrItem.item = item;
return;
}
if (elementHrItem.hide || elementHrItem.item.nextElementSibling.nodeName === "hr") {
elementHrItem.item.style.display = 'none';
}
elementHrItem.item = item;
elementHrItem.hide = true;
} else {
if (item.style.display === 'block' && elementHrItem.hide) {
elementHrItem.hide = false;
}
}
})
if (!!elementHrItem.item && elementHrItem.hide) elementHrItem.item.style.display = 'none';

菜单事件处理

本次右键重构另一特点就是事件绑定与元素绑定分离了,上一版的右键每次打开右键时都需要经历一次事件绑定,本次重构将事件处理部分拆分,核心的实现如下,如果是内置事件,调用 fn[eventName](),其余的视作用户自定义脚本,除特殊需要判断的其余根据 id 调用 RightMenusFunction 函数。

item.addEventListener('click', () => {
if (RightMenus.defaultEvent.every(item => { return eventName !== item })) {
if (groupName === 'seletctText') {
RightMenusFunction[id](globalData.selectText)
} else if (groupName === 'elementCheck') {
RightMenusFunction[id](globalData.isLink ? globalData.linkUrl : globalData.mediaLinkUrl)
} else if (groupName === 'elementImage') {
RightMenusFunction[id](globalData.mediaLinkUrl)
} else {
RightMenusFunction[id]()
}
} else {
fn[eventName]()
}
})

内置事件改进

与上一版自定义右键相比,最大的改动是重写了图片复制函数,上一版只能复制 PNG 格式的图片,局限性很大,这一版理论上支持复制所有图片。改动点在于将图片利用 canvas 画出来了,转换成 canvas 后利用 toBlob() 函数获得到了 image/png 的数据,如此完美。

注:虽然设置了 crossOrigin,但是实际使用时发现相同访问地址下会触发浏览器缓存文件,还是可能会出现 CORS 问题,解决方案是地址后面加参数刷新一下,视作不同链接。

{
writeClipImg: async (link, success, error) => {
const image = new Image;
image.crossOrigin = "Anonymous";
image.addEventListener('load', () => {
let canvas = document.createElement("canvas");
let context = canvas.getContext("2d");
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0);
canvas.toBlob(blob => {
navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]).then(e => {
success(e)
}).catch(e => {
error(e)
})
}, 'image/png')
}, false)
image.src = `${link}?(lll¬ω¬)`;
}
}

四、自定义

额外的,按照自定义事件方式实现了文章页下的『查看上/下一篇』,文本选中时的『复制为 Markdown』功能。

事件实现
<script type="text/javascript" src="/js/h2m.js"></script>
<script>
window.onkeydown = e => {
if (e.ctrlKey && e.key === 'a')
e.preventDefault();
}

volantis.rightmenu.jump = (type) => {
const item = document.querySelector(type === 'prev' ? 'article .prev-next a.prev' : 'article .prev-next a.next');
if(!!item) {
if(typeof pjax !== 'undefined') {
pjax.loadUrl(item.href)
} else {
window.location.href = item.href;
}
}
}

volantis.rightmenu.html2md = () => {
const selectionObj = window.getSelection();
const rangObj = selectionObj.getRangeAt(0);
const docFragment = rangObj.cloneContents();
const htmlStr = volantis.rightmenu.htmlHandle(docFragment);
const markdown = h2m(htmlStr);
VolantisApp.utilWriteClipText(markdown);
}

volantis.rightmenu.htmlHandle = documentFragment => {
const element = document.createElement("div");
element.appendChild(documentFragment);
volantis.rightmenu.removeElement([
'div#bottom', 'div.new-meta-box', 'div.prev-next', 'div.atk-avatar',
'div.atk-footer', 'div.atk-main-editor', 'div.atk-list-header',
'div.atk-height-limit-btn', 'div.atk-list-read-more', 'div.references',
'#l_cover', '#rightmenu-wrapper', '#s-top',
'head', 'header', 'footer', 'script', 'pjax'
], element)
window.selectHtml = element.innerHTML;
return element.innerHTML.replace(/(<button(.*?)btn-copy(.*?)<\/button>)/g, '')
.replace(/<figcaption>(.*?)<\/figcaption>/g, '')
}

volantis.rightmenu.removeElement = (selects = [], element = document) => {
selects.forEach(select => {
element.querySelectorAll(select).forEach(item => {
item.remove();
})
})
}

volantis.rightmenu.handle(() => {
const prev = document.querySelector('#prev').parentElement,
next = document.querySelector('#next').parentElement,
articlePrev = document.querySelector('article .prev-next a.prev p.title'),
articleNext = document.querySelector('article .prev-next a.next p.title');

prev.style.display = articlePrev ? 'block' : 'none';
prev.title = articlePrev ? articlePrev.innerText : null;
next.style.display = articleNext ? 'block' : 'none';
next.title = articleNext ? articleNext.innerText : null;
}, 'prevNext', false)
</script>

评论

:doodle { @grid: 1x5 / 100vmin; } @place-cell: center; width: @rand(45vmin, 75vmin); height: @rand(45vmin, 75vmin); transform: translate(@rand(-120%, 120%), @rand(-80%, 80%)) scale(@rand(.8, 2.8)) skew(@rand(45deg)); clip-path: polygon( @r(0, 30%) @r(0, 50%), @r(30%, 60%) @r(0%, 30%), @r(60%, 100%) @r(0%, 50%), @r(60%, 100%) @r(50%, 100%), @r(30%, 60%) @r(60%, 100%), @r(0, 30%) @r(60%, 100%) ); background: @pick(#f44336, #9c27b0, #673ab7, #3f51b5, #60569e, #e6437d, #ebbf4d, #00bcd4, #03a9f4, #2196f3, #009688, #5ee463, #f8e645, #ffc107, #ff5722, #43f8bf, #e136eb, #32ed39); opacity: @rand(.5, .9); position: relative; top: @rand(-80%, 80%); left: @rand(0%, 80%); animation: colorChange @rand(6.1s, 26.1s) infinite @rand(-.5s, -2.5s) linear alternate; @keyframes colorChange { 100% { left: 0; top: 0; filter: hue-rotate(360deg); } }