js 实现斜杠菜单展开并支持左右方向键

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();
                }
            }
        });
    }
})();
image.png

留下你的脚步
推荐阅读