JPDB Draggable Menu

The submenu is now a draggable element on the site.

Mint 2025.08.11.. Lásd a legutóbbi verzió

// ==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();
})();