缘起
初用思源时,总觉少了 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);
如何使用
戛然而止!
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于