Skip to content

Instantly share code, notes, and snippets.

@azzgo
Last active July 15, 2025 15:20
Show Gist options
  • Select an option

  • Save azzgo/06a5fc68ab25114dede322e999f29ac5 to your computer and use it in GitHub Desktop.

Select an option

Save azzgo/06a5fc68ab25114dede322e999f29ac5 to your computer and use it in GitHub Desktop.
油猴脚本-提供基本的菜单 UI
// ==UserScript==
// @name UI Booster Starter (rc-menu, no snap)
// @namespace https://www.baidu.com/
// @version 0.3.0
// @description Draggable FAB + right-click menu (no edge snapping)
// @author YourName
// @match *://*/*
// @grant GM_addStyle
// ==/UserScript==
/* ----------- ① 公共方法 ----------- */
const debounce = (fn, delay = 3000) => {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
clearTimeout(timeoutId);
fn.apply(this, args);
}, delay);
};
};
const ls_get = (key, defaultValue) => {
try {
let item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (e) {
console.error(e);
return defaultValue;
}
};
const ls_set = (key, value) => {
localStorage.setItem(key, JSON.stringify(value));
};
const { useState, useEffect } = (function () {
const effectStack = [];
const _subscribe = (effect, subs) => {
subs.add(effect);
effect.deps.add(subs);
};
const useState = (initialValue) => {
let _state = initialValue;
const _subs = new Set();
const getter = () => {
const effect = effectStack.at(-1);
if (effect) {
_subscribe(effect, _subs);
}
return _state;
};
const setter = (value) => {
_state = value;
// very important, execute all effects that depend on this state
// 这里不直接遍历 _subs 是因为可能会在执行过程中添加新的 effect,导致无限递归
const effects = Array.from(_subs);
for (const effect of effects) {
effect.execute();
}
};
return [getter, setter];
};
const _cleanup = (effect) => {
for (const subs of effect.deps) {
subs.delete(effect);
}
effect.deps.clear();
};
const useEffect = (fn) => {
const execute = () => {
_cleanup(effect);
effectStack.push(effect);
try {
fn();
} finally {
effectStack.pop();
}
};
const effect = {
execute,
deps: new Set(),
};
execute();
};
return { useState, useEffect };
})();
/* ---------- ② 样式:可随意覆盖 / 扩展 ---------- */
GM_addStyle(/*css*/ `
:root{
--fab-size:56px;
--fab-bg:#007aff;
--fab-color:#fff;
--fab-shadow:0 4px 12px rgba(0,0,0,.15);
--menu-bg:rgba(255,255,255,.96);
--menu-shadow:0 8px 24px rgba(0,0,0,.18);
--menu-radius:12px;
--menu-item-hover:rgba(0,0,0,.06);
--menu-divider:rgba(0,0,0,.08);
--menu-backdrop:blur(8px);
}
#tm-fab{
all:unset;
position:fixed;
bottom:88px;
right:24px;
width:var(--fab-size);
height:var(--fab-size);
border-radius:50%;
background:var(--fab-bg);
color:var(--fab-color);
display:flex;
align-items:center;
justify-content:center;
box-shadow:var(--fab-shadow);
cursor:grab;
user-select:none;
z-index:2147483647;
transition:background .25s;
}
#tm-fab:active{cursor:grabbing;transform:scale(.96)}
#tm-fab:hover{background:color-mix(in srgb,var(--fab-bg) 85%,#000)}
#tm-menu{
position:fixed;
top:0;left:0; /* 由 JS 实时计算 */
min-width:160px;max-width:240px;
background:var(--menu-bg);
border-radius:var(--menu-radius);
box-shadow:var(--menu-shadow);
backdrop-filter:var(--menu-backdrop);
padding:4px 0;
font-size:14px;color:#111;
overflow:hidden;
transform:scale(0);
opacity:0;
pointer-events:none;
transition:.18s ease;
z-index:2147483647;
}
#tm-menu.open{transform:scale(1);opacity:1;pointer-events:auto}
.tm-menu-item{
padding:10px 16px;
cursor:pointer;
transition:background .2s;
}
.tm-menu-item:hover{background:var(--menu-item-hover)}
.tm-menu-divider{
height:1px;margin:4px 0;background:var(--menu-divider);
}`);
/* ---------------- ③ DOM 构建 ---------------- */
let ls_menu_location_key = "tm_menu_location";
const fab = Object.assign(document.createElement("button"), {
id: "tm-fab",
title: "右键打开菜单",
innerHTML: "≡",
});
const location = ls_get(ls_menu_location_key);
if (location) {
fab.style.left = location.x + "px";
fab.style.top = location.y + "px";
}
const menu = Object.assign(document.createElement("div"), { id: "tm-menu" });
document.body.append(fab, menu);
/* ---------------- ④ 渲染菜单 ---------------- */
function renderMenu(list) {
menu.innerHTML = "";
list.forEach((it, idx) => {
if (it === "-" || it.divider) {
menu.appendChild(
Object.assign(document.createElement("div"), {
className: "tm-menu-divider",
}),
);
return;
}
const el = Object.assign(document.createElement("div"), {
className: "tm-menu-item",
});
switch (typeof it.label) {
case "function":
useEffect(() => {
el.textContent = it.label();
});
break;
case "string":
default:
el.textContent = it.label ?? `项 ${idx}`;
}
el.addEventListener("click", (e) => {
e.stopPropagation();
toggleMenu(false);
try {
it.action?.();
} catch (err) {
console.error(err);
}
});
menu.appendChild(el);
});
}
/* -------------- ⑤ 菜单展开/定位 -------------- */
function repositionMenu() {
if (!menu.classList.contains("open")) return;
const gap = 8;
const rf = fab.getBoundingClientRect();
// 让菜单先可见才能测量
menu.style.transform = "scale(1)";
menu.style.opacity = "0";
const mh = menu.offsetHeight,
mw = menu.offsetWidth;
// 先尝试放上方不足再放下方
let top = rf.top - mh - gap;
let above = true;
if (top < 8) {
above = false;
top = rf.bottom + gap;
}
let left = rf.right - mw;
left = Math.max(8, Math.min(window.innerWidth - mw - 8, left));
menu.style.transformOrigin = above ? "bottom right" : "top right";
menu.style.top = top + "px";
menu.style.left = left + "px";
menu.style.opacity = "";
}
let outsideClickListener = null;
function handleClickOutside(e) {
if (!menu.contains(e.target) && !fab.contains(e.target)) {
toggleMenu(false);
}
}
function toggleMenu(force) {
const open = force !== undefined ? force : !menu.classList.contains("open");
menu.classList.toggle("open", open);
if (open) {
requestAnimationFrame(repositionMenu);
if (!outsideClickListener) {
outsideClickListener = handleClickOutside;
document.addEventListener("mousedown", outsideClickListener);
document.addEventListener("touchstart", outsideClickListener);
}
} else {
if (outsideClickListener) {
document.removeEventListener("mousedown", outsideClickListener);
document.removeEventListener("touchstart", outsideClickListener);
outsideClickListener = null;
}
}
}
window.addEventListener("resize", repositionMenu);
/* -------------- ⑥ 拖拽逻辑 ------------------ */
(function drag(ele) {
let dx = 0,
dy = 0,
dragging = false;
const unify = (e) => (e.touches ? e.touches[0] : e);
const remberPosition = debounce((x, y) => {
ls_set(ls_menu_location_key, { x, y });
}, 300);
const start = (e) => {
// 仅左键拖动
if ((e.button ?? 0) !== 0) return;
const ev = unify(e);
dragging = true;
const r = ele.getBoundingClientRect();
dx = ev.clientX - r.left - r.width / 2;
dy = ev.clientY - r.top - r.height / 2;
document.addEventListener("mousemove", move);
document.addEventListener("touchmove", move, { passive: false });
document.addEventListener("mouseup", end);
document.addEventListener("touchend", end);
};
const move = (e) => {
if (!dragging) return;
const ev = unify(e);
if (e.cancelable) e.preventDefault();
let x = ev.clientX - dx - ele.offsetWidth / 2;
let y = ev.clientY - dy - ele.offsetHeight / 2;
x = Math.max(8, Math.min(window.innerWidth - ele.offsetWidth - 8, x));
y = Math.max(8, Math.min(window.innerHeight - ele.offsetHeight - 8, y));
ele.style.left = x + "px";
ele.style.top = y + "px";
ele.style.right = "auto";
ele.style.bottom = "auto";
remberPosition(x, y);
if (menu.classList.contains("open")) repositionMenu();
};
const end = () => {
dragging = false;
document.removeEventListener("mousemove", move);
document.removeEventListener("touchmove", move);
document.removeEventListener("mouseup", end);
document.removeEventListener("touchend", end);
repositionMenu();
};
ele.addEventListener("mousedown", start);
ele.addEventListener("touchstart", start, { passive: false });
})(fab);
/* -------------- ⑦ 右键控制菜单 -------------- */
fab.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
toggleMenu();
});
menu.addEventListener("contextmenu", (e) => e.preventDefault()); // 防止菜单上冒泡
/* -------------- ⑧ 扩展点:定义菜单,渲染菜单 -------------- */
const [count, setCounter] = useState(0);
const MENU_ITEMS = [
{ label: "刷新页面", action: () => location.reload() },
"-",
{ label: "示例一", action: () => alert("示例一") },
{ label: () => `计数: ${count()}`, action: () => setCounter(count() + 1) },
];
renderMenu(MENU_ITEMS);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment