Last active
July 15, 2025 15:20
-
-
Save azzgo/06a5fc68ab25114dede322e999f29ac5 to your computer and use it in GitHub Desktop.
油猴脚本-提供基本的菜单 UI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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