一、前缘 Volantis 的右键大体来说是我维护的,它的由来其实很有趣:当时是做了全局鼠标形状替换,但是在右键菜单展现时图标的修改被打回了原形,所以就萌生了替换掉右键菜单的想法。第一版的右键是仿照主题的菜单实现的,样式直接引用自菜单,功能上只是单纯的链接跳转。
后来 xaoxuu 觉得这个不错,就添加了顶部导航栏、音乐控制模块等,将自定义右键抽成了独立的功能,如此算是有了一个良好的开端;接着就是一些功能上的增减,主要围绕剪切板和内置事件展开;再后来 Volantis 开始去 JQ 化,右键也就跟着摘掉依赖,如此迭代后,才形成了目前主题内置的右键状态。
此时的右键菜单,所有的功能类按钮的事件实现全部内置,用户在配置方面只能做到新建一些静态链接、调调顺序罢了,而在代码实现上,逻辑实现提示挺弯弯绕的,灵活性极差。功能少自然配置上也就简单一些,这算是另类的优点吧。基本的,目前无法手动添加事件执行类的菜单项目,同时内部的菜单项是写死在页面中的,如果想要对这部分进行修改只能去改源码。另外也无法自行响应右键在不同状态下打开时的行为,所以主要的改进有两点:菜单动态生成、支持自定义脚本。
二、设计 后来也看到了其他博客的右键实现,比如糖果屋、Heo 博客的,不过理想中最完善的自定义右键是 Code-Server,奈何这类大项目真读起来还是太费时间的就没有太过研究,不过重写右键的想法确实被勾起了。原先在右键的功能上的实现有过一些积累,见文章 “前端页面的自定义右键与剪切板操作实现 ”,一并可以拿来参考,接着就是重新设计右键菜单的实现了。
配置文件 重构是从配置文件开始的,由于要实现动态菜单,我需要一个统一的菜单对象:
- {id: '' , name: '' , icon: '' , link: '' , event: '' , group: '' }
id
, name
, icon
作为基本的属性就不多提了,用来匹配菜单名称和图标;对于链接性质的菜单项,直接将地址填写进 link
中,使用时渲染成 a
链接即可;而对于事件形式的菜单项,写入到 event
里,这里就需要区分内置事件和用户自行输入的事件,所以维护一个内置事件列表,如果输入内容与内置事件匹配,调用右键菜单的内置实现,其余的当作自定义脚本执行;而 group
主要用于区分响应行为上,比如只在图片上右键时才显示菜单项,同样维护一个内置组。
一个组中可以拥有单个或多个菜单项,内置组根据右键时的状态确定组内菜单是否显示。在不考虑二级菜单的情况下,为了减少整个菜单的数量(长度),含有 event
的组显示时隐藏掉含有 link
项的菜单:基本链接型的菜单都是用户添加,功能型的菜单动态出现。
启用及排序 为了进一步区分,菜单项分为 plugins
和 menus
两大类,在使用上通过 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; }
拿到菜单对象后,就是根据其中内容解析成对应元素,以 event
和 link
的值做主要区分,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 >