[js] 好玩:模仿 obsidian 自滚动菜单,丝滑!

缘起

初用思源时,总觉少了 Obsidian 那种“自滚动菜单”般的丝滑感,总有些不惯。

随着日久相伴,渐渐融入思源的节奏与呼吸,那份执念也如旧梦般悄然褪色,沉入记忆的深处。

我以为,它早已随风而逝,再不会想起。

可初恋般的悸动,从来都不曾真正离去。

就在几小时前,与 @ACai 大佬闲谈之际,一句轻语,竟如微风拂过心湖,唤醒了那段尘封的回忆。

刹那间,心火重燃——

于是,我毅然推掉了那几十亿的大项目,实现了这个功能。

效果

使用该代码前,需要滚轮滚动

使用该代码后,丝滑!(录屏原因看起来有轻微抖动,实际没有)

代码

新版(完美版,已解决 0.0.6 存在的问题):

// 好玩:模仿obsidian自滚动菜单,丝滑!
// 参考自obsidian源码(已编译源码)
// version 0.0.7
// 0.0.7 从根本上解决子菜单跳动问题并避免视窗变化时菜单错乱问题;修复鸿蒙手机菜未跳过问题,感谢@5kyfkr反馈
// 0.0.6 彻底解决鼠标移入移出子菜单时,子菜单出现轻微跳动问题
// 0.0.5 增强兼容性,末级菜单不屏蔽事件
// 0.0.4 修复鼠标从主菜单移向子菜单时,子菜单会出现跳动的问题
// 0.0.3 增加为防止菜单过多时滚动过快,当菜单过多时恢复不自滚动 感谢@JeffreyChen提出
// 0.0.2 支持子菜单;去除菜单滚动条
setTimeout(() => {
    // 菜单最大限制,超过这个条数将不开启该功能
    // 设置为0,将忽略菜单数,永远开启该功能
    // 这里菜单数不是指总菜单数,是指将要滚动的菜单数
    // 感谢@JeffreyChen提出 https://ld246.com/article/1753713199859/comment/1753769776064?r=wilsons#comments
    const maxMenuItems = 50;
    
    // 触碰设备和手机跳过
    if(("ontouchstart" in window) && navigator.maxTouchPoints > 1 || !!document.getElementById("sidebar")) return;
  
    // 全局变量
    let lastMouseoverMenu = null; // 上次鼠标悬停的菜单
    let lastShowMenu = null; // 上次鼠显示的菜单
    
    // 重置菜单函数
    window.siyuan.menus.menu.showSubMenu = showSubMenu;
    const originRemoveMenu = window.siyuan.menus.menu.remove;
    window.siyuan.menus.menu.remove = removeMenu;
    
    // 绑定菜单
    const commonMenu = document.querySelector('#commonMenu > .b3-menu__items');
    if(!commonMenu) return;
    scrollElementByMousePosition(commonMenu);
    // 设置样式
    const style = document.createElement('style');
    style.textContent = `#commonMenu .b3-menu__items{overflow: hidden; button:has(.b3-menu__submenu){& > .b3-menu__label,& > .b3-menu__icon{pointer-events:none;}}}`;
    document.head.appendChild(style);
  
    // 钳位函数,将一个数值限制在指定的最小值和最大值之间
    // 如果提供的数值小于最小值,则返回最小值;如果数值大于最大值,则返回最大值;如果数值在最小值和最大值之间,则返回数值本身
    Math.clamp || (Math.clamp = function(value, min, max) {
        // 返回value、min和max三个值中的最小最大值,即确保value在min和max之间
        return Math.min(Math.max(value, min), max);
    });

    // 根据鼠标位置滚动元素
    function scrollElementByMousePosition(element) {
        let target;
        // 鼠标移入顶级父菜单时记录子菜单顶部位置
        element.addEventListener("mouseover", function(mouseEvent) {
            lastMouseoverMenu = null;
            if(mouseEvent.target.matches('.b3-menu__submenu')) return;
            if(mouseEvent.target.matches('.b3-menu__item')) lastMouseoverMenu = mouseEvent.target;
            else if(mouseEvent.target.parentElement.matches('.b3-menu__item')) lastMouseoverMenu = mouseEvent.target.parentElement;
        });
        // 鼠标移动事件
        element.addEventListener("mousemove", function(mouseEvent) {
            target = element;
            // 鼠标移动到子菜单
            if(mouseEvent.target.closest('.b3-menu__submenu')) {
                target = mouseEvent.target.closest('.b3-menu__submenu .b3-menu__items');
            }
            if(!target) return;
            // 为防止菜单过多时滚动过快,当菜单过多时恢复不自滚动
            // https://ld246.com/article/1753713199859/comment/1753769776064?r=wilsons#comments
            if(maxMenuItems && target?.children?.length > maxMenuItems) {
                target.style.overflow = 'auto';
                return;
            }
            // 计算菜单滚动位置
            var visibleHeight = target.clientHeight;
            var totalScrollHeight = target.scrollHeight;
            if (totalScrollHeight > visibleHeight) {
                var adjustedMouseY = mouseEvent.clientY - target.getBoundingClientRect().top - 30;
                var scrollPercentage = adjustedMouseY / (visibleHeight - 60);
                // 钳位函数,将一个数值限制在指定的最小值和最大值之间
                scrollPercentage = Math.clamp(scrollPercentage, 0, 1);
                target.scrollTop = (totalScrollHeight - visibleHeight) * scrollPercentage;
            }
        });
    }
    // see https://github.com/siyuan-note/siyuan/blob/5f0f4e3ba6aabd5af26f9879695e5b9b796b5fb9/app/src/menus/Menu.ts#L63C12-L80C6
    function showSubMenu(subMenuElement) {
        if(!lastMouseoverMenu) return;
        const subMenuParentElement = subMenuElement.parentElement;
        // 鼠标在父菜单且子菜单已显示时跳过
        if(subMenuParentElement === lastMouseoverMenu && lastShowMenu === subMenuElement) return;
        // 仅当鼠标在父菜单时才显示,防止重复显示带来的跳动问题
        const isSubMenuParent = !!lastMouseoverMenu?.querySelector('.b3-menu__submenu');

        // 计算子菜单位置
        const itemRect = subMenuParentElement.getBoundingClientRect();
        if(isSubMenuParent) subMenuElement.style.top = (itemRect.top - 8) + "px";
        subMenuElement.style.left = (itemRect.right + 8) + "px";
        if(isSubMenuParent) subMenuElement.style.bottom = "auto";
        const rect = subMenuElement.getBoundingClientRect();
        if (rect.right > window.innerWidth) {
            if (itemRect.left - 8 > rect.width) {
                subMenuElement.style.left = (itemRect.left - 8 - rect.width) + "px";
            } else {
                subMenuElement.style.left = (window.innerWidth - rect.width) + "px";
            }
        }
        if (rect.bottom > window.innerHeight) {
            if(isSubMenuParent) subMenuElement.style.top = "auto";
            if(isSubMenuParent) subMenuElement.style.bottom = "8px";
        }
        lastShowMenu = subMenuElement;
    }
    function removeMenu(...args) {
        lastShowMenu = null; // 删除菜单时,清除上次显示的菜单
        return originRemoveMenu.apply(window.siyuan.menus.menu, args);
    }
}, 2000);

旧版(v0.0.6,当窗口拖动后,菜单显示可能会乱,需要重新打开菜单):

// 好玩:模仿obsidian自滚动菜单,丝滑!
// 参考自obsidian源码(已编译源码)
// version 0.0.6
// 0.0.6 彻底解决鼠标移入移出子菜单时,子菜单出现轻微跳动问题
// 0.0.5 增强兼容性,末级菜单不屏蔽事件
// 0.0.4 修复鼠标从主菜单移向子菜单时,子菜单会出现跳动的问题
// 0.0.3 增加为防止菜单过多时滚动过快,当菜单过多时恢复不自滚动 感谢@JeffreyChen提出
// 0.0.2 支持子菜单;去除菜单滚动条
setTimeout(() => {
    // 菜单最大限制,超过这个条数将不开启该功能
    // 设置为0,将忽略菜单数,永远开启该功能
    // 这里菜单数不是指总菜单数,是指将要滚动的菜单数
    // 感谢@JeffreyChen提出 https://ld246.com/article/1753713199859/comment/1753769776064?r=wilsons#comments
    const maxMenuItems = 50;
  
    if(("ontouchstart" in window) && navigator.maxTouchPoints > 1) return; // 触碰设备跳过
  
    // 绑定菜单
    const commonMenu = document.querySelector('#commonMenu > .b3-menu__items');
    if(!commonMenu) return;
    scrollElementByMousePosition(commonMenu);
    // 设置样式
    const style = document.createElement('style');
    style.textContent = `#commonMenu .b3-menu__items{overflow: hidden; button:has(.b3-menu__submenu){& > .b3-menu__label,& > .b3-menu__icon{pointer-events:none;}}}`;
    document.head.appendChild(style);
  
    // 钳位函数,将一个数值限制在指定的最小值和最大值之间
    // 如果提供的数值小于最小值,则返回最小值;如果数值大于最大值,则返回最大值;如果数值在最小值和最大值之间,则返回数值本身
    Math.clamp || (Math.clamp = function(value, min, max) {
        // 返回value、min和max三个值中的最小最大值,即确保value在min和max之间
        return Math.min(Math.max(value, min), max);
    });

    // 根据鼠标位置滚动元素
    function scrollElementByMousePosition(element) {
        let target;
        // 鼠标移入顶级父菜单时记录子菜单顶部位置
        element.addEventListener("mouseover", function(mouseEvent) {
            const firstSubMenuParent = element.querySelector('.b3-menu__item--show');
            const firstSubMenu = firstSubMenuParent?.querySelector('.b3-menu__submenu');
            if(firstSubMenu) {
                firstSubMenuParent.dataset.subMenuTop = firstSubMenu.style.top;
            }
        });
        // 鼠标移动事件
        element.addEventListener("mousemove", function(mouseEvent) {
            const setFirstSubMenuTop = (mouseEvent) => {
                const firstSubMenuParent = mouseEvent.target.closest('[data-sub-menu-top]');
                const firstSubMenu = firstSubMenuParent?.querySelector('.b3-menu__submenu');
                const oldTop = firstSubMenuParent.dataset.subMenuTop;
                if(firstSubMenu && firstSubMenu.style.top !== oldTop) firstSubMenu.style.top = oldTop;
            };
            target = element;
            // 鼠标移动到子菜单
            if(mouseEvent.target.closest('.b3-menu__submenu')) {
                target = mouseEvent.target.closest('.b3-menu__submenu .b3-menu__items');
                setFirstSubMenuTop(mouseEvent);
            }
            // 鼠标移动到顶级父菜单
            if((mouseEvent.target?.matches('[data-sub-menu-top]')||mouseEvent.target?.parentElement?.matches('[data-sub-menu-top]'))) {
                setFirstSubMenuTop(mouseEvent);
            }
            if(!target) return;
            // 为防止菜单过多时滚动过快,当菜单过多时恢复不自滚动
            // https://ld246.com/article/1753713199859/comment/1753769776064?r=wilsons#comments
            if(maxMenuItems && target?.children?.length > maxMenuItems) {
                target.style.overflow = 'auto';
                return;
            }
            // 计算菜单滚动位置
            var visibleHeight = target.clientHeight;
            var totalScrollHeight = target.scrollHeight;
            if (totalScrollHeight > visibleHeight) {
                var adjustedMouseY = mouseEvent.clientY - target.getBoundingClientRect().top - 30;
                var scrollPercentage = adjustedMouseY / (visibleHeight - 60);
                // 钳位函数,将一个数值限制在指定的最小值和最大值之间
                scrollPercentage = Math.clamp(scrollPercentage, 0, 1);
                target.scrollTop = (totalScrollHeight - visibleHeight) * scrollPercentage;
            }
        });
    }
}, 2000);

如何使用

如何使用代码片段? - 思源笔记社区平台

戛然而止!

  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    28446 引用 • 119772 回帖
  • 代码片段

    代码片段分为 CSS 与 JS 两种代码,添加在 [设置 - 外观 - 代码片段] 中,这些代码会在思源笔记加载时自动执行,用于改善笔记的样式或功能。

    用户在该标签下分享代码片段时需在帖子标题前添加 [css] [js] 用于区分代码片段类型。

    285 引用 • 1985 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
wilsons
正式入驻知乎了,以后新贴主要在这里。 欢迎大家订阅关注! 你的关注对我是莫大鼓励,也能让我持续产出优质内容,我们一起成长 🙏 点这里立即关注:https://www.zhihu.com/people/wilsonses