The submenu is now a draggable and transparency-adjustable element on the site.
目前為
// ==UserScript==
// @name JPDB Draggable Menu
// @namespace https://greasyfork.org/users/you
// @version 1.2.0
// @description The submenu is now a draggable and transparency-adjustable element on the site.
// @match https://jpdb.io/*
// @run-at document-idle
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const TUNE = {
friction: 0.92, minSpeed: 12, maxSpeed: 6000,
kickBase: 10, kickGain: 0.006, kickMin: 8, kickMax: 36, kickDur: 160, kickCooldown: 140,
velWindowMs: 160, releaseQuietMs: 140, releaseQuietPx: 2,
anchorSense: 12, lockSense: 2,
intentX: 16, intentY: 14, intentSpeed: 420
};
GM_addStyle(`
:root{
--jpdbfSideW: 32px;
--jpdbfSideInsetX: 8px;
--jpdbfSideInsetY: 6px;
--jpdbfOpNudgeY: -1.5px;
--jpdbfOpPad: 28px;
}
.jpdbf-float{
position:fixed!important; z-index:2147483647!important; max-width:80vw; width:280px;
background:rgba(20,20,22,var(--jpdbfAlpha,1)); color:#e7e7e7; border:1px solid #3a3a3a;
border-radius:14px; box-shadow:0 16px 40px rgba(0,0,0,.5);
padding:10px; box-sizing:border-box; cursor:grab; user-select:none; touch-action:none;
overflow:hidden;
}
.jpdbf-float:active{cursor:grabbing}
.jpdbf-float input[type="submit"], .jpdbf-float button{
border-radius:10px!important; cursor:pointer;
opacity: var(--jpdbfBtnOpacity, 1);
transition: opacity .12s ease;
}
.jpdbf-float .main-row{ position:relative!important; }
.jpdbf-float #show-checkbox-1-label.side-button{
position:absolute!important;
top:var(--jpdbfSideInsetY)!important;
bottom:var(--jpdbfSideInsetY)!important;
left:var(--jpdbfSideInsetX)!important;
width:var(--jpdbfSideW)!important;
display:flex!important; align-items:center!important; justify-content:center!important;
margin:0!important; padding:0!important; border-radius:10px!important;
opacity: var(--jpdbfBtnOpacity, 1); /* ⮟ fades with slider */
transition: opacity .12s ease;
}
.jpdbf-float.jpdbf-has-chevron .main.column{
margin-left:calc(var(--jpdbfSideW) + var(--jpdbfSideInsetX) + 8px)!important;
}
.jpdbf-float.jpdbf-op-open{ padding-bottom:var(--jpdbfOpPad); }
.jpdbf-op-row{
margin:8px 0 0;
padding:4px 6px 0;
width:100%; box-sizing:border-box;
display:grid; grid-template-columns:1fr minmax(56px,auto);
align-items:center; gap:10px;
}
.jpdbf-op-row input[type=range]{
width:100%; height:18px; margin:0; background:transparent;
appearance:none; -webkit-appearance:none
}
.jpdbf-op-row input[type=range]::-webkit-slider-runnable-track{ height:8px; border-radius:9999px; background:#2b2b2e }
.jpdbf-op-row input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none; appearance:none; margin-top:-4px; width:16px; height:16px; border-radius:9999px; background:#d7d7d7; border:1px solid #666
}
.jpdbf-op-row input[type=range]::-moz-range-track{ height:8px; border-radius:9999px; background:#2b2b2e }
.jpdbf-op-row input[type=range]::-moz-range-thumb{
width:16px; height:16px; border-radius:9999px; background:#d7d7d7; border:1px solid #666
}
.jpdbf-op-val{
display:flex; align-items:center; justify-content:flex-end;
white-space:nowrap; font-size:12px; font-variant-numeric:tabular-nums; opacity:.95;
min-width:5ch; text-align:right; line-height:1;
transform: translateY(var(--jpdbfOpNudgeY));
}
`);
const STATE_KEY = 'jpdbf:pos:v1';
const OPACITY_KEY = 'jpdbf:alpha:v1';
const qs=(s,r=document)=>r.querySelector(s);
const qsa=(s,r=document)=>Array.from(r.querySelectorAll(s));
function findMenu(){
const g = qs('#grade-1, #grade-2, #grade-3, #grade-4, #grade-5');
if (g){ const mr=g.closest('.main-row'); if (mr) return mr; let c=g.parentElement; while(c&&c!==document.body){ if(qsa('input[type="submit"]',c).length>=2) return c; c=c.parentElement; } }
const show = qs('#show-answer') || qs('input[type="submit"][value="Show answer"]');
if (show){ const mr2=show.closest('.main-row'); if (mr2) return mr2; const f=show.closest('form'); if (f) { const r=f.closest('.main-row')||f.parentElement; if(r) return r; } return show.parentElement; }
return null;
}
function detectMode(el){
if (!el) return null;
if (el.querySelector('#grade-1, #grade-2, #grade-3, #grade-4, #grade-5')) return 'gr';
if (el.querySelector('#show-answer, input[type="submit"][value="Show answer"]')) return 'sa';
return null;
}
function clamp(l,t,w,h){ const vw=innerWidth,vh=innerHeight; if(l<0)l=0; if(t<0)t=0; if(l+w>vw)l=vw-w; if(t+h>vh)t=vh-h; return {left:l,top:t}; }
const loadState=()=>{ try{return JSON.parse(localStorage.getItem(STATE_KEY)||'null');}catch{return null;} };
const saveState=s=>{ try{localStorage.setItem(STATE_KEY,JSON.stringify(s));}catch{} };
function computeLocksFromRect(r){ const vw=innerWidth,vh=innerHeight,L=TUNE.lockSense;
return {lockL:r.left<=L,lockR:(vw-(r.left+r.width))<=L,lockT:r.top<=L,lockB:(vh-(r.top+r.height))<=L}; }
function computeEdgeAnchorsWith(r, E){
const vw=innerWidth,vh=innerHeight;
const dL=r.left, dT=r.top, dR=vw-r.right, dB=vh-r.bottom;
const nL=dL<=E, nR=dR<=E, nT=dT<=E, nB=dB<=E;
if(nB&&nR) return {ax:'right',ay:'bottom'};
if(nB&&nL) return {ax:'left', ay:'bottom'};
if(nT&&nR) return {ax:'right',ay:'top'};
if(nT&&nL) return {ax:'left', ay:'top'};
if(nR) return {ax:'right',ay:'top'};
if(nB) return {ax:'left', ay:'bottom'};
if(nL) return {ax:'left', ay:'top'};
if(nT) return {ax:'left', ay:'top'};
return {ax:'left', ay:'top'};
}
const computeEdgeAnchors = r => computeEdgeAnchorsWith(r, TUNE.anchorSense);
function distancesToEdges(r){
const vw=innerWidth,vh=innerHeight;
return { dL:r.left, dT:r.top, dR:vw-(r.left+r.width), dB:vh-(r.top+r.height) };
}
function intentFrom(r, vx, vy){
const {dL,dT,dR,dB} = distancesToEdges(r);
const nearL = (dL <= TUNE.lockSense) || (Math.abs(vx) >= TUNE.intentSpeed && dL <= TUNE.intentX);
const nearR = (dR <= TUNE.lockSense) || (Math.abs(vx) >= TUNE.intentSpeed && dR <= TUNE.intentX);
const nearT = (dT <= TUNE.lockSense) || (Math.abs(vy) >= TUNE.intentSpeed && dT <= TUNE.intentY);
const nearB = (dB <= TUNE.lockSense) || (Math.abs(vy) >= TUNE.intentSpeed && dB <= TUNE.intentY);
if (nearB && nearR) return {ax:'right', ay:'bottom'};
if (nearB && nearL) return {ax:'left', ay:'bottom'};
if (nearT && nearR) return {ax:'right', ay:'top'};
if (nearT && nearL) return {ax:'left', ay:'top'};
if (nearR) return {ax:'right', ay:'top'};
if (nearB) return {ax:'left', ay:'bottom'};
if (nearL) return {ax:'left', ay:'top'};
if (nearT) return {ax:'left', ay:'top'};
return null;
}
function currentOffsets(r,a){ const vw=innerWidth,vh=innerHeight;
return {offR:a.ax==='right'?(vw-(r.left+r.width)):0, offB:a.ay==='bottom'?(vh-(r.top+r.height)):0}; }
function resolveAnchorsWithLocks(s){ const ax=s.lockR?'right':(s.lockL?'left':s.ax); const ay=s.lockB?'bottom':(s.lockT?'top':s.ay); return {ax,ay}; }
function applyAnchoredPosition(w,h,e){ const vw=innerWidth,vh=innerHeight; const use=resolveAnchorsWithLocks(e);
let l=e.x, t=e.y; if(use.ax==='right') l=Math.max(0,Math.min(vw-w,vw-w-(e.offR||0))); if(use.ay==='bottom') t=Math.max(0,Math.min(vh-h,vh-h-(e.offB||0)));
return clamp(l,t,w,h); }
function entryFromRect(r){ const a=computeEdgeAnchors(r), locks=computeLocksFromRect(r), offs=currentOffsets(r,a);
return {x:r.left,y:r.top,ax:a.ax,ay:a.ay,offR:offs.offR,offB:offs.offB,...locks}; }
function saveFromRect(r){ const st=loadState()||{}; st.pos=entryFromRect(r); localStorage.setItem(STATE_KEY,JSON.stringify(st)); }
function loadAlpha(){ const v=Number(localStorage.getItem(OPACITY_KEY)); if(Number.isFinite(v) && v>=0.2 && v<=1) return v; return 1; }
function saveAlpha(v){ localStorage.setItem(OPACITY_KEY, String(v)); }
function applyOpacityTo(el){ const v = loadAlpha(); el.style.setProperty('--jpdbfAlpha', String(v)); el.style.setProperty('--jpdbfBtnOpacity', String(v)); }
function ensureOpacityUI(){
if(!floatingEl) return; const hb = floatingEl.querySelector('.hidden-body'); if(!hb) return;
if(hb.querySelector('.jpdbf-op-row')) return;
const row = document.createElement('div'); row.className = 'row jpdbf-op-row';
const rng = document.createElement('input'); rng.type='range'; rng.min='20'; rng.max='100'; rng.step='1'; rng.value=String(Math.round(loadAlpha()*100)); rng.setAttribute('aria-label','Opacity');
const val = document.createElement('div'); val.className='jpdbf-op-val'; val.textContent = rng.value+'%';
rng.addEventListener('input', ()=>{ const p=Math.max(20,Math.min(100,Number(rng.value))); val.textContent=p+'%'; const v=p/100; if(floatingEl){ floatingEl.style.setProperty('--jpdbfAlpha',String(v)); floatingEl.style.setProperty('--jpdbfBtnOpacity',String(v)); } saveAlpha(v); });
row.appendChild(rng); row.appendChild(val); hb.appendChild(row);
const checkbox = floatingEl.querySelector('#show-checkbox-1'); if (checkbox) { const sync=()=>{ if(checkbox.checked) floatingEl.classList.add('jpdbf-op-open'); else floatingEl.classList.remove('jpdbf-op-open'); }; checkbox.addEventListener('change', sync); sync(); }
}
let floatingEl=null, dragging=false, startX=0, startY=0, startLeft=0, startTop=0;
let vx=0, vy=0, raf=0, lastMoveX=0, lastMoveY=0, lastMoveT=0;
let rx=null, ry=null, lastKickX=0, lastKickY=0;
const stopAnim=()=>{ if(raf) cancelAnimationFrame(raf); raf=0; };
const samples=[];
function pushSample(x,y,t){ samples.push({x,y,t}); const cut=t-TUNE.velWindowMs; while(samples.length&&samples[0].t<cut) samples.shift(); }
function releaseVelocity(){ if(samples.length<2) return {vx:0,vy:0};
let S1=0,St=0,Sx=0,Sy=0,Stt=0,Stx=0,Sty=0; for(const s of samples){ S1+=1; St+=s.t; Sx+=s.x; Sy+=s.y; Stt+=s.t*s.t; Stx+=s.t*s.x; Sty+=s.t*s.y; }
const den=(S1*Stt-St*St)||1; let VX=(S1*Stx-St*Sx)/den*1000; let VY=(S1*Sty-St*Sy)/den*1000;
const sp=Math.hypot(VX,VY); if(sp>TUNE.maxSpeed){ const k=TUNE.maxSpeed/sp; VX*=k; VY*=k; } return {vx:VX,vy:VY}; }
function writeAndSave(x,y){ floatingEl.style.left=x+'px'; floatingEl.style.top=y+'px'; const r=floatingEl.getBoundingClientRect(); saveFromRect(r); }
function startInertia(initVx,initVy){
stopAnim(); vx=initVx; vy=initVy; rx=null; ry=null;
function tick(prev){
raf=requestAnimationFrame(ts=>{
const dt=Math.min(0.05,Math.max(0.001,(ts-prev)/1000)), now=performance.now(), f=Math.pow(TUNE.friction,dt*60);
vx*=f; vy*=f; const r=floatingEl.getBoundingClientRect(); let nx=r.left+vx*dt, ny=r.top+vy*dt;
const vw=innerWidth,vh=innerHeight; let hitL=false,hitR=false,hitT=false,hitB=false; const preVx=vx,preVy=vy;
if(nx<0){nx=0;hitL=true} if(nx+r.width>vw){nx=vw-r.width;hitR=true}
if(ny<0){ny=0;hitT=true} if(ny+r.height>vh){ny=vh-r.height;hitB=true}
if((hitL||hitR)&&!rx&&(now-lastKickX>TUNE.kickCooldown)){const k=Math.max(TUNE.kickMin,Math.min(TUNE.kickMax,TUNE.kickBase+TUNE.kickGain*Math.abs(preVx))); const to=hitL?Math.min(k,vw-r.width):Math.max(vw-r.width-k,0); rx={from:nx,to,t0:now,dur:TUNE.kickDur}; vx=0; lastKickX=now;}
if((hitT||hitB)&&!ry&&(now-lastKickY>TUNE.kickCooldown)){const k=Math.max(TUNE.kickMin,Math.min(TUNE.kickMax,TUNE.kickBase+TUNE.kickGain*Math.abs(preVy))); const to=hitT?Math.min(k,vh-r.height):Math.max(vh-r.height-k,0); ry={from:ny,to,t0:now,dur:TUNE.kickDur}; vy=0; lastKickY=now;}
if(rx){const p=Math.min(1,(now-rx.t0)/rx.dur); nx=rx.from+(rx.to-rx.from)*(1-Math.pow(1-p,3)); if(p>=1) rx=null;}
if(ry){const p=Math.min(1,(now-ry.t0)/ry.dur); ny=ry.from+(ry.to-ry.from)*(1-Math.pow(1-p,3)); if(p>=1) ry=null;}
writeAndSave(nx,ny);
if((vx*vx+vy*vy)<(TUNE.minSpeed*TUNE.minSpeed)&&!rx&&!ry){raf=0;return}
tick(ts);
});
}
raf=requestAnimationFrame(tick);
}
function freezeAndSave(){ if(!floatingEl) return; stopAnim(); rx=null; ry=null; saveFromRect(floatingEl.getBoundingClientRect()); }
function syncChevronFlag(){ if(!floatingEl) return; const has = !!floatingEl.querySelector('#show-checkbox-1-label.side-button'); floatingEl.classList.toggle('jpdbf-has-chevron', has); }
function attachDrag(el){
if(el._jpdbfBound) return; el._jpdbfBound=true;
el.addEventListener('pointerdown', e=>{
if(e.button!==0) return;
if(e.target.closest('input,button,select,textarea,label,a,[role="button"],[contenteditable="true"]')) return;
stopAnim(); rx=null; ry=null; dragging=true;
const r=el.getBoundingClientRect(); startX=e.clientX; startY=e.clientY; startLeft=r.left; startTop=r.top;
samples.length=0; const now=performance.now(); pushSample(e.clientX,e.clientY,now); lastMoveX=e.clientX; lastMoveY=e.clientY; lastMoveT=now;
e.preventDefault(); try{el.setPointerCapture(e.pointerId);}catch{}
}, true);
window.addEventListener('pointermove', e=>{
if(!dragging) return;
const r=el.getBoundingClientRect(); const nx=startLeft+(e.clientX-startX); const ny=startTop+(e.clientY-startY);
const c=clamp(nx,ny,r.width,r.height); writeAndSave(c.left,c.top);
const now=performance.now(); pushSample(e.clientX,e.clientY,now); lastMoveX=e.clientX; lastMoveY=e.clientY; lastMoveT=now;
});
window.addEventListener('pointerup', e=>{
if(!dragging) return;
dragging=false; try{el.releasePointerCapture(e.pointerId);}catch{}
const now=performance.now(); pushSample(e.clientX,e.clientY,now);
const quietTime=now-lastMoveT; const quietDist=Math.hypot(e.clientX-lastMoveX,e.clientY-lastMoveY);
if(quietTime>=TUNE.releaseQuietMs && quietDist<=TUNE.releaseQuietPx){ freezeAndSave(); return; }
const v=releaseVelocity(); startInertia(v.vx,v.vy);
});
const subObs = new MutationObserver(()=>syncChevronFlag());
subObs.observe(el, {childList:true, subtree:true, attributes:true});
window.addEventListener('resize', ()=>{
if(!floatingEl) return;
const st=loadState(); if(!st||!st.pos) return;
const r0=floatingEl.getBoundingClientRect(); const pos=applyAnchoredPosition(r0.width,r0.height,st.pos);
writeAndSave(pos.left,pos.top);
});
document.addEventListener('click', (e)=>{
if(!floatingEl || !floatingEl.contains(e.target)) return;
const t=e.target;
if(t.matches('#show-answer, input[type="submit"][value="Show answer"]')){ freezeAndSave(); return; }
if(t.matches('input[type="submit"], button[type="submit"]')){ freezeAndSave(); }
if(t.matches('#show-checkbox-1-label')) setTimeout(ensureOpacityUI, 0);
}, true);
document.addEventListener('submit', (e)=>{
if(!(floatingEl && floatingEl.contains(e.target))) return;
freezeAndSave();
}, true);
document.addEventListener('keydown', (e)=>{
if(!floatingEl || !floatingEl.contains(e.target)) return;
if(e.key==='Enter') freezeAndSave();
}, true);
window.addEventListener('pagehide', freezeAndSave, {capture:true});
window.addEventListener('beforeunload', freezeAndSave, {capture:true});
document.addEventListener('visibilitychange', ()=>{ if(document.visibilityState==='hidden') freezeAndSave(); }, {capture:true});
}
let sizeObs=null, prevW=null, prevH=null;
function startSizeObserver(el){
if(sizeObs) try{sizeObs.disconnect();}catch{}
const init=el.getBoundingClientRect(); prevW=init.width; prevH=init.height;
sizeObs=new ResizeObserver(()=>{
if(!floatingEl) return;
const r=el.getBoundingClientRect(); const newW=r.width,newH=r.height;
if(prevW==null||prevH==null){prevW=newW;prevH=newH;return;}
const dw=newW-prevW, dh=newH-prevH; if(dw===0&&dh===0) return;
let newLeft=r.left, newTop=r.top;
const st=loadState(); if(st&&st.pos){
const use=resolveAnchorsWithLocks(st.pos);
if(dw!==0 && use.ax==='right') newLeft=r.left-dw;
if(dh!==0 && use.ay==='bottom') newTop=r.top-dh;
}
const c=clamp(newLeft,newTop,newW,newH); el.style.left=c.left+'px'; el.style.top=c.top+'px';
saveFromRect(el.getBoundingClientRect());
prevW=newW; prevH=newH;
});
sizeObs.observe(el);
}
function applySaved(el){
const st=loadState(); const r0=el.getBoundingClientRect();
if(st&&st.pos){
const p=applyAnchoredPosition(r0.width,r0.height,st.pos); el.style.left=p.left+'px'; el.style.top=p.top+'px';
saveFromRect(el.getBoundingClientRect());
}else{
const c=clamp(Math.max(0,innerWidth-r0.width-20), Math.max(0,innerHeight-r0.height-20), r0.width, r0.height);
el.style.left=c.left+'px'; el.style.top=c.top+'px'; saveFromRect(el.getBoundingClientRect());
}
}
function floatIt(el){
if(!el || floatingEl===el) return;
if(floatingEl && floatingEl.isConnected){
floatingEl.classList.remove('jpdbf-float','jpdbf-op-open','jpdbf-has-chevron');
floatingEl.style.left=''; floatingEl.style.top='';
}
floatingEl=el;
el.classList.add('jpdbf-float');
syncChevronFlag();
applySaved(el);
applyOpacityTo(el);
attachDrag(el);
startSizeObserver(el);
if(detectMode(el)==='gr') ensureOpacityUI();
}
function boot(){ const c=findMenu(); if(c) floatIt(c); }
new MutationObserver(()=>{ boot(); }).observe(document.documentElement,{childList:true,subtree:true});
let lastHref=location.href;
setInterval(()=>{ if(location.href!==lastHref){ lastHref=location.href; boot(); } }, 400);
boot(); setTimeout(boot, 600);
})();