// ==UserScript==
// @name JPDB Draggable Menu
// @namespace https://greasyfork.org/users/you
// @version 1.0.1
// @description The submenu is now a draggable 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: 20, lockSense: 2
};
GM_addStyle(`
.jpdbf-float{position:fixed!important;z-index:2147483647!important;max-width:80vw;width:280px;background:rgba(20,20,22,.94);color:#e7e7e7;border:1px solid #3a3a3a;border-radius:14px;box-shadow:0 16px 40px rgba(0,0,0,.5);padding:10px 10px 12px;cursor:grab;user-select:none;touch-action:none}
.jpdbf-float:active{cursor:grabbing}
.jpdbf-float input[type="submit"],.jpdbf-float button{border-radius:10px!important;cursor:pointer}
`);
const STATE_KEY = 'jpdbf:pos:v1';
const qs=(s,r=document)=>r.querySelector(s);
const qsa=(s,r=document)=>Array.from(r.querySelectorAll(s));
const visible=el=>el&&el.getBoundingClientRect().width>0&&el.getBoundingClientRect().height>0&&getComputedStyle(el).visibility!=='hidden'&&getComputedStyle(el).display!=='none';
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'); return f?f.parentElement: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}; }
// --- Edge helpers with adjustable margin -------------------------------
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;
// corners first
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'};
// edges
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'};
// default
return {ax:'left', ay:'top'};
}
const computeEdgeAnchors = r => computeEdgeAnchorsWith(r, TUNE.anchorSense);
function nearAnyEdgeRect(r, E=TUNE.anchorSense){
const vw=innerWidth,vh=innerHeight;
const dL=r.left, dT=r.top, dR=vw-r.right, dB=vh-r.bottom;
return (dL<=E||dR<=E||dT<=E||dB<=E);
}
// -----------------------------------------------------------------------
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 applyEntry(el,e){ const r0=el.getBoundingClientRect(); const p=applyAnchoredPosition(r0.width,r0.height,e); el.style.left=p.left+'px'; el.style.top=p.top+'px'; }
function saveFromRect(r){ const st=loadState()||{}; st.pos=entryFromRect(r); saveState(st); }
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 makeFlushEntryFromAnchors(r,a){ const e=entryFromRect(r); e.ax=a.ax; e.ay=a.ay;
if(a.ax==='right') e.offR=0; if(a.ax==='left') e.x=0; if(a.ay==='bottom') e.offB=0; if(a.ay==='top') e.y=0;
e.lockL=(a.ax==='left'); e.lockR=(a.ax==='right'); e.lockT=(a.ay==='top'); e.lockB=(a.ay==='bottom'); return e; }
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;
let currentMode=null;
// SA→GR context
let growOverride=null;
let saBeforeSwitch=null;
// GR→SA: inherit edge only if user intended (drag release OR grade click near edge, with wider margin)
let grUserEdgeIntent=null; // {ax,ay}
const INTERACTIVE='input,button,select,textarea,label,a,[role="button"],[contenteditable="true"]';
const easeOutCubic=t=>1-Math.pow(1-t,3);
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'; saveFromRect(floatingEl.getBoundingClientRect()); }
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)*easeOutCubic(p); if(p>=1) rx=null;}
if(ry){const p=Math.min(1,(now-ry.t0)/ry.dur); ny=ry.from+(ry.to-ry.from)*easeOutCubic(p); 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 attachDrag(el){
if(el._jpdbfBound) return; el._jpdbfBound=true;
el.addEventListener('pointerdown', e=>{
if(e.button!==0) return;
if(e.target.closest(INTERACTIVE)) 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);
// Intent detection on release in GR with WIDER margin (handles bottom bounce)
if((detectMode(floatingEl)||currentMode)==='gr'){
const r=floatingEl.getBoundingClientRect();
const intentMargin = Math.max(TUNE.anchorSense, TUNE.kickMax + 6);
grUserEdgeIntent = nearAnyEdgeRect(r, intentMargin) ? computeEdgeAnchorsWith(r, intentMargin) : null;
}
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);
});
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;
// SA -> GR
if(t.matches('#show-answer, input[type="submit"][value="Show answer"]')){
const r=floatingEl.getBoundingClientRect();
growOverride={...computeEdgeAnchors(r),pending:true};
saBeforeSwitch=entryFromRect(r);
freezeAndSave();
return;
}
// GR submit (grade) — also treat being near an edge at click as intent (with wider margin)
if(t.matches('input[type="submit"], button[type="submit"]')){
if((detectMode(floatingEl)||currentMode)==='gr'){
const r=floatingEl.getBoundingClientRect();
const intentMargin = Math.max(TUNE.anchorSense, TUNE.kickMax + 6);
if(nearAnyEdgeRect(r, intentMargin)) grUserEdgeIntent = computeEdgeAnchorsWith(r, intentMargin);
}
freezeAndSave();
}
}, true);
document.addEventListener('submit', (e)=>{
if(!(floatingEl && floatingEl.contains(e.target))) return;
const btn=e.submitter;
if(btn && (btn.matches('#show-answer') || (btn.matches('input[type="submit"]') && btn.value==='Show answer'))){
const r=floatingEl.getBoundingClientRect();
growOverride={...computeEdgeAnchors(r),pending:true};
saBeforeSwitch=entryFromRect(r);
} else {
if((detectMode(floatingEl)||currentMode)==='gr'){
const r=floatingEl.getBoundingClientRect();
const intentMargin = Math.max(TUNE.anchorSense, TUNE.kickMax + 6);
if(nearAnyEdgeRect(r, intentMargin)) grUserEdgeIntent = computeEdgeAnchorsWith(r, intentMargin);
}
}
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;
if((detectMode(floatingEl)||currentMode)==='gr' && growOverride && growOverride.pending){
if(dw>0 && growOverride.ax==='right') newLeft=r.left-dw;
if(dh>0 && growOverride.ay==='bottom') newTop=r.top-dh;
growOverride.pending=false;
} else {
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 onModeChange(prev,next){
if(next==='gr'){
// entering GR
grUserEdgeIntent=null;
} else if(next==='sa'){
// If user intended an edge in GR (via release or grade click), inherit that; else go back to prior SA spot.
if(grUserEdgeIntent){
const rNow=floatingEl.getBoundingClientRect();
const flush=makeFlushEntryFromAnchors(rNow, grUserEdgeIntent);
applyEntry(floatingEl, flush);
saveFromRect(floatingEl.getBoundingClientRect());
} else if (saBeforeSwitch){
applyEntry(floatingEl, saBeforeSwitch);
saveFromRect(floatingEl.getBoundingClientRect());
}
grUserEdgeIntent=null; growOverride=null; saBeforeSwitch=null;
}
}
function floatIt(el){
if(!el || floatingEl===el) return;
if(floatingEl && floatingEl.isConnected){ floatingEl.classList.remove('jpdbf-float'); floatingEl.style.left=''; floatingEl.style.top=''; }
floatingEl=el;
el.classList.add('jpdbf-float');
applySaved(el);
attachDrag(el);
startSizeObserver(el);
currentMode=detectMode(el);
}
function boot(){ const c=findMenu(); if(c && visible(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);
setInterval(()=>{
if(!floatingEl) return;
const m=detectMode(floatingEl);
if(m && m!==currentMode){ const prev=currentMode; currentMode=m; onModeChange(prev,m); }
}, 120);
boot();
})();