js
// js实现斜杠菜单展开并支持左右方向键
(() => {
// 使用兼容模式,
// 如果左右箭头有问题,可以使用兼容模式,兼容模式用ctrl/cmd + 方向键移动
// 默认false,未开启,设为true开启
const useCompatibilityMode = false;
// 搜索时,虚拟分组列表跳转位置
// currpos 直接跳转到下一列的相同位置
// first 跳转到下一列的第一个元素那里,默认first
const searchGroupSkipPos = "first";
// 校正因子
// 虚拟分组情况下,当左右方向键跳转有错误时,可以通过微调该参数进行校正,
// 此时控制台会输出当前计算的分组大小,只要输出结果和实际一致就可以了
// 可输入正值或负值或小数
const correctionFactor = 0;
////////////// 以下代码不涉及配置项,如无必要勿动 //////////////////////////
// 筛选时虚拟列表跳过元素个数
// 由于筛选列表没有分组,这里用跳过元素数代替,这个变量会自动修改,手动修改无效
let skipElementNumInSearch = 3;
// 初始化最后一个分组的大小,用于分组筛选时方向键跳转的判断依据,会实时计算
let lastGroupSize = 1;
// 判断是否默认主题
//const theme = siyuan.config.appearance.mode === 0 ? siyuan.config.appearance.themeLight : siyuan.config.appearance.themeDark;
//if(theme !== 'midnight' && theme !== 'daylight') return;
// 注入css,解决第一个元素边距导致的列表内容不对齐
const dialogStyle = document.createElement('style');
dialogStyle.textContent = `
.hint--menu:not(.fn__none) button:nth-child(1) {
margin-top: 0;
}
`;
document.head.appendChild(dialogStyle);
// 设置下一个元素的焦点
function nextElementFocus(nextElement) {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const currentFocus = menu.querySelector('.b3-list-item--focus');
currentFocus.classList.remove("b3-list-item--focus");
nextElement?.classList.add("b3-list-item--focus");
}
// 获取下一个分组元素的焦点
function focusNextGroupButton() {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const currentFocus = menu.querySelector('.b3-list-item--focus');
let nextElement = currentFocus.nextElementSibling;
// 继续查找下一个元素,直到找到一个按钮或.b3-menu__separator
while (nextElement && nextElement.classList.contains('b3-list-item') && !nextElement.classList.contains('b3-menu__separator')) {
nextElement = nextElement.nextElementSibling;
}
// 如果找到了.b3-menu__separator,就聚焦到它的前一个按钮
if (nextElement && nextElement.classList.contains('b3-menu__separator')) {
nextElement = nextElement.nextElementSibling;
}
// 如果没有找到任何按钮或.b3-menu__separator,循环到列表开头
if (!nextElement || !nextElement.classList.contains('b3-list-item')) {
nextElement = menu.querySelector('.b3-list-item');
}
nextElementFocus(nextElement);
}
// 获取上一个分组元素的焦点
function focusPreviousGroupButton() {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const currentFocus = menu.querySelector('.b3-list-item--focus');
let previousElement = currentFocus.previousElementSibling;
// 继续查找上一个元素,直到找到一个按钮或.b3-menu__separator
while (previousElement && previousElement.classList.contains('b3-list-item') && !previousElement.classList.contains('b3-menu__separator')) {
previousElement = previousElement.previousElementSibling;
}
// 如果找到了.b3-menu__separator,就聚焦到它的前一个按钮
if (previousElement && previousElement.classList.contains('b3-menu__separator')) {
previousElement = previousElement.previousElementSibling;
}
// 如果没有找到任何按钮或.b3-menu__separator,循环到列表结尾
if (!previousElement || !previousElement.classList.contains('b3-list-item')) {
previousElement = menu.querySelector('.b3-list-item:last-child');
}
nextElementFocus(getGroupFirstElement(previousElement));
}
// 获取分组的第一个元素
function getGroupFirstElement(currentFocus) {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
let previousElement = currentFocus.previousElementSibling;
// 继续查找上一个元素,直到找到一个按钮或.b3-menu__separator
while (previousElement && previousElement.classList.contains('b3-list-item') && !previousElement.classList.contains('b3-menu__separator')) {
previousElement = previousElement.previousElementSibling;
}
// 如果找到了.b3-menu__separator,就聚焦到它的前一个按钮
if (previousElement && previousElement.classList.contains('b3-menu__separator')) {
previousElement = previousElement.nextElementSibling;
}
// 如果没有找到任何按钮或.b3-menu__separator,循环到列表结尾
if (!previousElement || !previousElement.classList.contains('b3-list-item')) {
previousElement = menu.querySelector('.b3-list-item');
}
return previousElement;
}
// 获取上一个元素
function focusPreviousButton(type='') {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const currentFocus = menu.querySelector('.b3-list-item--focus');
let previousElement = currentFocus.previousElementSibling;
if(type === ''){
if(previousElement.classList.contains("b3-menu__separator")){
previousElement = previousElement.previousElementSibling;
}
}
if(!previousElement || !previousElement.classList.contains('b3-list-item')){
previousElement = menu.querySelector('.b3-list-item:last-child');
}
nextElementFocus(previousElement);
}
// 获取下一个元素
function focusNextButton(type='') {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const currentFocus = menu.querySelector('.b3-list-item--focus');
let nextElement = currentFocus.nextElementSibling;
if(type === ''){
if(nextElement.classList.contains("b3-menu__separator")){
nextElement = nextElement.nextElementSibling;
}
}
if(!nextElement || !nextElement.classList.contains('b3-list-item')){
nextElement = menu.querySelector('.b3-list-item');
}
nextElementFocus(nextElement);
}
// 获取上n个元素
function focusPreviousNButton() {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const currentFocus = menu.querySelector('.b3-list-item--focus');
let previousElement = currentFocus.previousElementSibling;
const num = menu.querySelectorAll('.b3-list-item').length;
if(skipElementNumInSearch >= num) {
focusPreviousButton('search');
return;
}
let count = 1;
while (previousElement && previousElement.classList.contains('b3-list-item') && count <= skipElementNumInSearch) {
previousElement = previousElement.previousElementSibling;
count++;
}
// 如果没有找到任何按钮,循环到列表开头
if (!previousElement || !previousElement.classList.contains('b3-list-item')) {
if(searchGroupSkipPos === "first") {
previousElement = menu.querySelector('.b3-list-item:nth-last-child('+lastGroupSize+')');
} else {
previousElement = menu.querySelector('.b3-list-item:last-child');
}
}
nextElementFocus(previousElement);
}
// 获取下n个元素
function focusNextNButton() {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const currentFocus = menu.querySelector('.b3-list-item--focus');
let nextElement = currentFocus.nextElementSibling;
const num = menu.querySelectorAll('.b3-list-item').length;
if(skipElementNumInSearch >= num) {
focusNextButton('search');
return;
}
let count = 1;
while (nextElement && nextElement.classList.contains('b3-list-item') && count <= skipElementNumInSearch) {
nextElement = nextElement.nextElementSibling;
count++;
}
// 如果没有找到任何按钮或.b3-menu__separator,循环到列表开头
if (!nextElement || !nextElement.classList.contains('b3-list-item')) {
nextElement = menu.querySelector('.b3-list-item');
}
nextElementFocus(nextElement);
}
// 计算计算虚拟列表大小
function calcSearchGroupSize(menu, type) {
// 计算方法
// 分组大小 = 父容器大小 / (button.ofoffsetHeight + button.margin)
const menuContent = menu.firstElementChild;
const menuContentHeiht = menuContent.offsetHeight;
const button = menu.querySelector("button.b3-list-item:nth-child(2)");
const buttonStyle = getComputedStyle(button, null);
const buttonMargin = parseFloat(buttonStyle.marginTop) + parseFloat(buttonStyle.marginBottom);
const buttonHeight = button.offsetHeight + buttonMargin;
let groupSize = Math.round((menuContentHeiht + (parseFloat(correctionFactor)||0)) / buttonHeight);
if(correctionFactor !== 0) console.log("当前分组大小", groupSize, "校正因子", correctionFactor);
//console.log(groupSize, menuContentHeiht, buttonHeight);
// 跳转方式
if(searchGroupSkipPos === 'first') {
// 跳转到下一列第一个元素位置
// 算法
// 1. 先计算当前在列表中的位置 = 当前所在条数 / 分组大小 取余
// 2 计算下一跳间隔数(下一分组首位置)= 分组大小 - 当前在列表中的位置
// 3 计算上一跳间隔数(上一分组首位置)= 当前在列表中的位置 - 1 + (分组大小 - 1)
const currentFocus = menu.querySelector('.b3-list-item--focus');
const currPos = getCurrPos(currentFocus);
// 当余数为0时,说明当前在列表中是最后一个元素,currPosInGroup应等于groupSize
const currPosInGroup = currPos % groupSize || groupSize;
if(type === 'next'){
// 下一跳
const nexPos = groupSize - currPosInGroup;
skipElementNumInSearch = nexPos;
} else {
// 上一跳
let prevPos = currPosInGroup - 1 + (groupSize - 1);
// 计算所有元素个数
const groupNum = menu.querySelectorAll('button.b3-list-item').length;
if(currPos <= groupSize) {
// 如果当前在第一列,则下一跳是最后一列,要计算最后一列分组的实际大小,可能不足groupSize
// 所有元素数 % 分组大小 即最后一列的实际大小,当为0时应取groupSize
lastGroupSize = groupNum % groupSize || groupSize;
// 当没有上一个元素了,会自动取最后最一组的倒数第lastGroupSize的那个元素(即最后最一组的第一个元素)
prevPos = currPosInGroup - 1 + (lastGroupSize - 1);
}
skipElementNumInSearch = prevPos;
}
} else {
// 跳转到上一列/下一列相同位置
// -1 是因为跳过n个元素,不含第一个或最后一个
skipElementNumInSearch = groupSize - 1;
}
}
// 获取焦点元素的当前位置
function getCurrPos(focusedItem) {
let count = 0;
// 遍历当前元素的所有前兄弟元素
for (let sibling = focusedItem.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
// 如果前兄弟元素是 button 并且拥有 b3-list-item 类,则计数加一
if (sibling.tagName.toLowerCase() === 'button' && sibling.classList.contains('b3-list-item')) {
count++;
}
}
return count + 1;
}
// 延迟执行
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 等待元素渲染完成后执行
function whenElementExist(selector) {
return new Promise(resolve => {
const checkForElement = () => {
let element = null;
if (typeof selector === 'function') {
element = selector();
} else {
element = document.querySelector(selector);
}
if (element) {
resolve(element);
} else {
requestAnimationFrame(checkForElement);
}
};
checkForElement();
});
}
let hintMenuShow = false;
let hintMenuTimer = null;
function monitorHintMenu(layoutCenter) {
// 定义一个回调函数处理 DOM 变化
const observerCallback = (mutationsList) => {
mutationsList.forEach((mutation) => {
if (mutation.type === 'childList') {
// 当 layout__center 元素有新的子元素被添加或删除时触发
mutation.addedNodes.forEach((node) => {
if (node.classList && node.classList.contains('hint--menu')) {
// 检查新添加的节点是否是 .hint--menu
checkFnNoneClass(node);
}
});
} else if (mutation.type === 'attributes' && mutation.target.classList.contains('hint--menu')) {
// 当 .hint--menu 元素的属性发生变化时触发
checkFnNoneClass(mutation.target);
}
});
};
// 检查是否有 .fn_none 类
function checkFnNoneClass(node) {
const hasFnNoneClass = node.classList.contains('fn__none');
if(!hasFnNoneClass){
// 显示menu
hintMenuShow = true;
if(hintMenuTimer) clearTimeout(hintMenuTimer);
} else {
// 隐藏menu
if(hintMenuTimer) clearTimeout(hintMenuTimer);
hintMenuTimer = setTimeout(()=>{ hintMenuShow = false; }, 100);
}
}
// 配置 MutationObserver
const config = { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] };
// 创建一个新的 MutationObserver 实例
const observer = new MutationObserver(observerCallback);
// 开始观察 layout__center 元素
observer.observe(layoutCenter, config);
// 返回一个函数以停止观察
return () => {
observer.disconnect();
};
}
// 监控menu显示状态
if(!useCompatibilityMode) {
// 等待标签页容器渲染完成后开始监听
whenElementExist('.layout__center').then(async element => {
monitorHintMenu(element);
});
}
// 监听按键
if(useCompatibilityMode) {
// 使用兼容模式
document.addEventListener('keydown', function(event) {
const menu = document.querySelector(".hint--menu:not(.fn__none)");
const sepEl = document.querySelector("div.b3-menu__separator");
if(menu && (event.ctrlKey || event.metaKey)){
if (event.key === 'ArrowRight') {
if(sepEl){
focusNextGroupButton();
} else {
// 计算虚拟列表大小
calcSearchGroupSize(menu, 'next');
// 跳过n个元素
focusNextNButton();
}
event.preventDefault();
event.stopPropagation();
} else if (event.key === 'ArrowLeft') {
if(sepEl){
focusPreviousGroupButton();
} else {
// 计算虚拟列表大小
calcSearchGroupSize(menu, 'prev');
// 跳过n个元素
focusPreviousNButton();
}
event.preventDefault();
event.stopPropagation();
} else if (event.key === 'Escape') {
menu.classList.add("fn__none");
event.preventDefault();
event.stopPropagation();
}
// ctrl + 上下箭头 思源监控不到
// else if (event.key === 'ArrowUp') {
// focusPreviousButton();
// }
// else if (event.key === 'ArrowDown') {
// focusNextButton();
// }
}
});
} else {
// 不使用兼容模式
document.addEventListener('keydown', function(event) {
const menu = document.querySelector(".hint--menu");
const sepEl = document.querySelector("div.b3-menu__separator");
if(menu && hintMenuShow){
menu.classList.remove("fn__none");
if (event.key === 'ArrowRight') {
if(sepEl){
focusNextGroupButton();
} else {
// 计算虚拟列表大小
calcSearchGroupSize(menu, 'next');
// 跳过n个元素
focusNextNButton();
}
event.preventDefault();
event.stopPropagation();
} else if (event.key === 'ArrowLeft') {
if(sepEl){
focusPreviousGroupButton();
} else {
// 计算虚拟列表大小
calcSearchGroupSize(menu, 'prev');
// 跳过n个元素
focusPreviousNButton();
}
event.preventDefault();
event.stopPropagation();
} else if (event.key === 'Escape') {
menu.classList.add("fn__none");
event.preventDefault();
event.stopPropagation();
}
}
});
}
})();