自定义右键重构篇

是的,自定义右键菜单再次尝试重构了。上一次重构是在 2022 年,但最近在尝试修改时发现,目前实现逻辑过于复杂且不够优雅,同时由于缺乏合适的注释,花了不少时间才理清调用关系, 惭愧。

因此,决定推倒重来,重新设计。

一、需求分析

菜单类型分为两类:导航栏和菜单项。导航栏:位于顶部(类似火狐浏览器的右键菜单),采用横向布局,仅显示图标。菜单项:采用竖向布局,显示图标和菜单名称,对应一般的普通菜单。

功能上也可分为两类:链接型菜单和事件型菜单。链接型菜单:记录网址和链接类型,例如:回到首页、查看留言板页面。事件型菜单:需要执行函数的菜单项,例如:复制文本、打印页面。

详细分析见下面的思维导图:

右键菜单思维导图

1.1 配置文件

Hexo 配置文件示例
rightmenus:
enable: true
options:
navigation: true # 是否显示导航栏组件
maxMenuItems: 12 # 最大菜单显示数量
navigation:
- { menuItem }
menuList:
- { menuItem }

navigationmenuList字段的定义顺序决定了菜单加载的显示顺序。在options中,navigation控制是否显示导航栏组件;当满足条件的菜单项数量超过maxMenuItems时,隐藏所有链接型菜单。在menuList菜单项中,如果相邻菜单项的displayCondition值不同,则自动添加分割线。也可以手动定义菜单分割线,此时自动判断会关闭,并在定义的位置添加分割线。

菜单分割线定义方式
hr, {}, {id: 'hr'}

对于 eventName 和 displayCondition 的值,右键菜单函数会预设一些事件和判断函数。在进行菜单项判断或点击事件触发时,首先会检查右键菜单函数中是否存在相应的实现。如果不存在,则会在 window 上尝试调用,同时传递 menuItem[1]、event 和 pointevent 值。具体的调用方式如下:

eventName
/**
* 在 window 上尝试按照 eventName 值调用函数
*
* @param {Object} menuItem 菜单项
* @param {Event} event 点击事件
* @param {PointerEvent} pointevent 右键事件
*/
window[eventName](menuItem, event, pointevent)()
displayCondition
/**
* 在 window 上尝试按照 displayCondition 值调用函数
*
* @param {Object} menuItem 菜单项
* @param {PointerEvent} pointevent 右键事件
* @returns {Boolean} - true/false 是否允许当前菜单项显示
*/
window[displayCondition](menuItem, pointevent)()

该调用支持简单的点分链式调用、参数传递和文本替换。详细的实现逻辑请见:3.3 外部方法

使用示例
- { eventName: 'readMode' } # 内部事件:切换阅读模式
- { eventName: 'window.location.reload' } # 刷新页面
- { eventName: 'volantis.dark.toggle' } # 切换深色模式
- { eventName: 'window.open(linkAddress)' } # 新标签页打开链接
- { eventName: 'OpenSearch(selectedText)' } # 打开搜索页面,并传递当前选中文本
- { eventName: 'window.open("https://www.bing.com/search?q=##selectedText##")' } # 必应搜索

1.2 页面绘制

menuItem 中的属性将以data-xxxx格式写入到菜单项的 HTML 元素上。

例外属性: linkTarget ➡ target, link ➡ href
<% if(item.link === undefined) { %>
<li class="menuLoad-Content">
<span class="vlts-menu" data-id="<%- item.id %>" data-event-name="<%= item.eventName %>"
data-display-condition="<%- item.displayCondition %>">
<i class="<%- item.icon %>"></i>
<%- item.name %>
</span>
</li>
<% } else { %>
<% if(!item.linkTarget) item.linkTarget = '_self' %>
<li class="menuLoad-Content">
<a class="vlts-menu" data-id="<%- item.id %>" href="<%- item.link %>"
target="<%- item.linkTarget %>" data-display-condition="<%- item.displayCondition %>">
<i class="<%- item.icon %>"></i>
<%- item.name %>
</a>
</li>
<% } %>

二、处理流程

大致处理流程如下:

👐 使用右键选择器在页面中找到 HTML 元素。

🙌 如果找到,则读取所有导航栏和菜单项赋值存储。

🤲 覆盖浏览器默认的右键菜单,绘制菜单项并显示自定义右键菜单。

🙏 为右键菜单点击事件添加事件委托,触发相应事件

右键处理流程

三、功能实现

如果是传统的 BS 项目,菜单的读取通常是通过调用 API 来完成的。言归正传,在这里,我们从页面中查找右键元素后,分别读取出导航栏和菜单项中的元素,并将它们赋值给 navigationItems[2] 和 menuItems。

const menuItems = Array.from(menuContainer.querySelectorAll('.menuLoad-Content'))
.flatMap(item => {
const elem = item.firstElementChild;
return elem ? [{
id: elem.dataset.id,
link: elem.href,
linkTarget: elem.target,
eventName: elem.dataset.eventName,
displayCondition: elem.dataset.displayCondition,
isHrElement: elem.tagName === 'HR',
menuContentElement: item
}] : [];
});

3.1 菜单绘制

主要涉及两个方面:菜单项的显示与隐藏、菜单的弹出位置。首先,我们定义一个conditions对象,其中包含基本的判断条件,这些条件的方法返回布尔值。这样,只需将displayCondition的值与conditions键值进行判断,即可区分内置判断条件和外部判断条件。目前内置了以下判断项目:

const conditions = {
inInputField: () => {},
selectedText: () => {},
onImage: () => {},
onLink: () => {},
articlePage: () => {},
scrolledFromTop: () => {},
homePage: () => {}
}

根据设定,如果displayCondition未定义,菜单项默认显示。这可能会导致显示的菜单项过多。在这种情况下,可以通过配置文件中的maxMenuItems值来控制显示数量。当显示数量超过此值时,隐藏所有链接型菜单项。除此之外,对于菜单分割项也需要额外处理,主要包括以下两点:

  • 因菜单项隐藏而导致相邻菜单分割项显示的场景。
  • 第一个或最后一个显示项为菜单分割项的场景。
let lastSeparator = null;
menuItems.forEach(menuItem => {
const classList = menuItem?.menuContentElement?.classList || null;
if (menuItem.isHrElement) {
classList.add('active');
if (lastSeparator) { lastSeparator.remove('active'); }
lastSeparator = classList;
} else if (classList.contains('active')) { lastSeparator = null; }
});
if (lastSeparator) { lastSeparator.remove('active'); }
if (navigationItems.length === 0) {
const firstActiveMenuItem = menuItems.find(
item => item.menuContentElement.classList.contains('active'))
if (firstActiveMenuItem.isHrElement) {
firstActiveMenuItem.menuContentElement.classList.remove('active');
}
}

最后,关于菜单的弹出位置,只需确保菜单显示在屏幕内即可。另一个需要考虑的是,当网页处于移动端布局时,取消自定义菜单覆盖。

3.2 菜单事件

与菜单绘制类似,我们通过定义实现对象eventHandlers,并根据键值来区分内部事件和外部事件。

/**
* 事件调用
*
* @param {string} id 事件 id
* @param {string} eventName 事件名称
* @param {Event} event 点击事件
*/
const handleEvent = (id, eventName, event) => {
const item = menuItems.find(item => item.eventName === eventName && item.id === id)
|| navigationItems.find(item => item.eventName === eventName && item.id === id)
|| { id: id, eventName: eventName };
if (eventHandlers[eventName]) {
eventHandlers[eventName](item, globalData.pointerEvent);
} else {
executeEvent('eventName', item, [event, globalData.pointerEvent]);
}
};

/**
* 菜单项点击事件(事件委托)
*/
menuContainer.addEventListener('click', event => {
const navigation = event.target.closest('.menuNavigation-Content a'); // 导航栏
const menuContent = event.target.closest('.menuLoad-Content span'); // 菜单项
const targetElement = navigation || menuContent;

if (targetElement && targetElement.dataset.eventName && targetElement.dataset.id) {
handleEvent(targetElement.dataset.id, targetElement.dataset.eventName, event);
}
// else 普通链接型 无需处理
});

3.3 外部方法

如 1.1 小节所述,在调用外部方法时,程序会尝试从window对象中读取执行。除此之外,此调用还支持简单的点分链式调用、参数传递和文本替换。对于一些简单的功能,例如希望实现回到上一页,只需要执行window.history.back,因此在配置文件中的eventName值里,就可以这样填写。

具体而言,对于传递过来的eventName值,首先使用正则表达式进行匹配,尝试获取functionPathfunctionArgs

const functionMatch = eventName.match(/^([^\(]+)\((.*)\)$/);

let functionPath, functionArgs;
if (functionMatch) {
functionPath = functionMatch[1].trim();
functionArgs = functionMatch[2].split(',').map(arg => arg.trim().replace(/['"]/g, ''));
} else {
functionPath = eventName.trim();
functionArgs = [];
}

另外,对于长度为一的参数,进行如下替换:根据属性值尝试匹配globalData[3] 的键值。例如,如果想要在新标签页中打开链接,可以这样定义:eventName: 'window.open(linkAddress)'。而对于需要传递字符串的调用,如百度搜索或必应搜索,可以在参数中用##包裹待替换的内容来标定,例如:

# selectedText 将被替换为选中文本
- {eventName: 'window.open("https://www.bing.com/search?q=##selectedText##")'}

最后,将functionPath拆分为数组,逐层访问对象属性,并记录上一层对象,以确保在调用函数时正确设置this。如果最终解析到的context是一个函数,则使用apply方法调用它。

const properties = functionPath.split('.');
let context = window;
let parentContext = null;

for (const prop of properties) {
parentContext = context;
context = context[prop];
if (typeof context === 'undefined') {
return;
}
}

if (typeof context === 'function') {
return context.apply(parentContext, [...functionArgs, ...args]);
}

3.4 实现代码

综上所述,完整的实现内容请参见以下链接中的文件:

四、附录内容

4.1 动态样式

诸如阅读模式和打印模式的功能设计,可以通过修改页面元素样式来实现。例如,在打印网页时,可以通过媒体查询media="print"引入专用于打印的样式文件。

<link rel="stylesheet" href="/css/print.css" media="print">

可以在开发者工具的样式编辑器中,开启模拟 CSS 媒体类型的功能,以调试打印样式。

开发者工具

对于阅读模式,可以通过控制 link 标签的 disabled 属性来决定样式是否生效。

<link id="reading-mode" rel="stylesheet" disabled="true" href="/css/read.css">

<script>
const readModeStylesheet = document.getElementById('reading-mode');
readModeStylesheet.disabled = !readModeStylesheet.disabled;
</script>

4.2 图片粘贴

一个问题:如果剪贴板中有图片,可以将其粘贴到输入框(评论框)中吗?

以下是 Artalk 评论系统中关于图片上传的处理定义:

const onPaste = (evt: ClipboardEvent) => {
const files = evt.clipboardData?.files
if (files?.length) {
evt.preventDefault()
uploadFromFileList(files)
}
}

this.kit.useMounted(() => {
this.kit.useUI().$textarea.addEventListener('dragover', onDragover)
this.kit.useUI().$textarea.addEventListener('drop', onDrop)
this.kit.useUI().$textarea.addEventListener('paste', onPaste)
})

在浏览器环境中,直接主动触发 paste 事件是不可行的(虽然在 Electron 中可以实现)。但是浏览器允许显示操作来读取剪贴板和处理粘贴事件。恰巧,我们此时有一个由用户主动触发的菜单点击事件。

由于无法修改 Artalk 的图片上传实现,我们需要另辟蹊径:从剪贴板读取内容,将图片文件添加到 DataTransfer 对象中,然后创建并触发一个 ClipboardEvent,将 DataTransfer 对象中的内容传递给输入框的粘贴事件中。

let imageFiles = [];
for (const item of clipboardItems) {
for (const type of item.types) {
if (type.startsWith('image/')) {
const imageBlob = await item.getType(type);
const file = new File([imageBlob], 'clipboard-image.png', { type: type });
imageFiles.push(file);
} else if (type === 'text/plain') {
//...
}
}
}

for (const file of imageFiles) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
const pasteEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
});

// 输入框触发粘贴事件
pointerEvent.target.dispatchEvent(pasteEvent);
}

  1. 注:此处menuItem对象未包含数据组成中的name, icon属性,但额外提供两个属性:

    {
    isHrElement: '是否为菜单分割项',
    menuContentElement: '菜单项所对应 DOM 节点'
    }
    ↩︎
  2. navigationItems 主要获取id,displayCondition,menuContentElement↩︎

  3. globalData 目前提供四个公共数据:

    const globalData = {
    pointerEvent: null, // 右键事件
    linkAddress: null, // 链接地址
    selectedText: null, // 选取文本
    inputContent: null // 输入框
    };
    ↩︎

评论