Bloxd.io Keyboard visualizer

A custom keyboard visualizer. Alt + M can custom keyboard.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bloxd.io Keyboard visualizer
// @namespace    https://github.com/code-copilot
// @version      1.4.0
// @description  A custom keyboard visualizer. Alt + M can custom keyboard.
// @match        *://*.bloxd.io/*
// @run-at       document-idle
// @noframes
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  'use strict';

  // ---------- 持久化設定 ----------
  const STORE = 'bloxd_keystrokes_i18n_v1';
  const DEF = {
    show: true,
    uiScale: 1.0,
    hudPos: { x: 100, y: 100 },
    panelPos: { x: 80, y: 60 },
    cpsWindowMs: 1000,
    baseColor: '#808080', baseAlpha: 0.75,
    activeColor: '#ffffff', activeAlpha: 0.28,
    outlineColor: '#000000', outlineAlpha: 0.25, outlineWidth: 1,
    rounded: false, radius: 0,
    lang: 'auto', // 新增
  };
  let S = load(); function load(){ try{ const v=GM_getValue(STORE); if(v&&typeof v==='object') return {...DEF, ...v}; }catch{} return {...DEF}; }
  function save(p){ S={...S,...p}; try{ GM_setValue(STORE,S);}catch{} }
  const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
  const toRGBA=(hex,a)=>{ const h=hex.replace('#',''); const n=h.length===3?h.split('').map(c=>c+c).join(''):h; const r=parseInt(n.slice(0,2),16),g=parseInt(n.slice(2,4),16),b=parseInt(n.slice(4,6),16); return `rgba(${r},${g},${b},${clamp(a,0,1)})`; };

  // ---------- 多語系 ----------
  const T = {
    'zh-TW': {
      title: '模組介面',
      toggle_keyboard: '鍵盤顯示器',
      base_color: '鍵帽底色',
      active_color: '按下覆蓋色',
      outline_color: '外框顏色',
      outline_width: '外框粗細(px)',
      rounded: '是否圓弧',
      radius: '圓角大小(px)',
      current_scale: '目前縮放',
      hint: '提示:Alt+M 面板;Enter+滾輪縮放(穩定)。右鍵不彈出選單以統計 RMB。',
      reset_pos: '重置位置',
      close: '關閉',
      lmb: '左鍵',
      rmb: '右鍵',
      cps: '次/秒',
      language: '語言',
      auto: '自動',
    },
    'zh-CN': {
      title: '模组界面',
      toggle_keyboard: '键盘显示器',
      base_color: '按键底色',
      active_color: '按下覆盖色',
      outline_color: '外框颜色',
      outline_width: '外框粗细(px)',
      rounded: '是否圆角',
      radius: '圆角大小(px)',
      current_scale: '当前缩放',
      hint: '提示:Alt+M 面板;Enter+滚轮缩放(稳定)。右键不弹出菜单以统计 RMB。',
      reset_pos: '重置位置',
      close: '关闭',
      lmb: '左键',
      rmb: '右键',
      cps: '次/秒',
      language: '语言',
      auto: '自动',
    },
    'en': {
      title: 'Module Panel',
      toggle_keyboard: 'Keyboard HUD',
      base_color: 'Key Base Color',
      active_color: 'Pressed Overlay',
      outline_color: 'Outline Color',
      outline_width: 'Outline Width (px)',
      rounded: 'Rounded Corners',
      radius: 'Corner Radius (px)',
      current_scale: 'Current Scale',
      hint: 'Tips: Alt+M panel; Enter+wheel to scale (stable). Context menu disabled for RMB.',
      reset_pos: 'Reset Position',
      close: 'Close',
      lmb: 'LMB',
      rmb: 'RMB',
      cps: 'CPS',
      language: 'Language',
      auto: 'Auto',
    },
    'ja': {
      title: 'モジュールパネル',
      toggle_keyboard: 'キーボードHUD',
      base_color: 'キー基色',
      active_color: '押下オーバーレイ',
      outline_color: '枠線色',
      outline_width: '枠線太さ(px)',
      rounded: '角丸',
      radius: '角丸サイズ(px)',
      current_scale: '現在スケール',
      hint: 'ヒント: Alt+M パネル、Enter+ホイールで拡大縮小(安定)。RMBのためにコンテキストメニュー無効。',
      reset_pos: '位置をリセット',
      close: '閉じる',
      lmb: '左ボタン',
      rmb: '右ボタン',
      cps: '回/秒',
      language: '言語',
      auto: '自動',
    },
    'ko': {
      title: '모듈 패널',
      toggle_keyboard: '키보드 HUD',
      base_color: '키 기본 색상',
      active_color: '눌림 오버레이',
      outline_color: '외곽선 색상',
      outline_width: '외곽선 두께 (px)',
      rounded: '라운드 처리',
      radius: '라운드 크기 (px)',
      current_scale: '현재 배율',
      hint: '팁: Alt+M 패널, Enter+휠로 배율 조정(안정). RMB 집계를 위해 컨텍스트 메뉴 비활성화.',
      reset_pos: '위치 초기화',
      close: '닫기',
      lmb: '좌클릭',
      rmb: '우클릭',
      cps: '회/초',
      language: '언어',
      auto: '자동',
    },
  };
  function resolveLang(){
    if (S.lang !== 'auto') return S.lang;
    const nav = (navigator.language || '').toLowerCase();
    if (nav.includes('zh') && (nav.includes('hant') || nav.includes('tw') || nav.includes('hk'))) return 'zh-TW';
    if (nav.includes('zh')) return 'zh-CN';
    if (nav.startsWith('ja')) return 'ja';
    if (nav.startsWith('ko')) return 'ko';
    return 'en';
  }
  function t(key){ const lang = resolveLang(); const dict = T[lang] || T['en']; return dict[key] ?? key; }

  // ---------- 樣式 ----------
  GM_addStyle(`
    :root{
      --ks-size: 50px; --ks-gap: 8px; --ks-bg: rgba(128,128,128,.75);
      --ks-active: rgba(255,255,255,.28); --ks-border-color: rgba(0,0,0,.25);
      --ks-border-width: 1px; --ks-radius: 0px; --ks-z: 2147483000;
    }
    .ks-hidden{ display:none!important; } .ks-noselect{ user-select:none; -webkit-user-select:none; }
    #visualizer{ position: fixed; z-index: var(--ks-z); top: 100px; left: 100px; display:flex; flex-direction:column; gap:var(--ks-gap); background:transparent; transform-origin: top left; }
    .row{ display:flex; justify-content:center; gap:var(--ks-gap); }
    .key{
      width:var(--ks-size); height:var(--ks-size); background-color:var(--ks-bg); color:#fff; font-weight:bold; font-size:16px;
      border-radius:var(--ks-radius); display:flex; flex-direction:column; align-items:center; justify-content:center;
      border:var(--ks-border-width) solid var(--ks-border-color); box-shadow:0 4px 0 rgba(0,0,0,.25); position:relative; text-align:center;
    }
    .key.active::after{ content:""; position:absolute; inset:0; border-radius:var(--ks-radius); background:var(--ks-active); }
    #key-lmb,#key-rmb{ width: calc(var(--ks-size)*1.5 + var(--ks-gap)); }
    #key-lmb{ margin-left: calc(-1 * (var(--ks-size)*0.5) - var(--ks-gap)/2); }
    #key-rmb{ margin-right: calc(-1 * (var(--ks-size)*0.5) - var(--ks-gap)/2); }
    #key-space{ width: calc(var(--ks-size)*3 + var(--ks-gap)*2); }
    .cps{ font-size:12px; margin-top:2px; color:#fff; }

    #ks-panel{
      position:fixed; z-index:var(--ks-z); min-width:380px; padding:12px; background:rgba(18,18,22,.96); color:#eaeaea;
      border:1px solid rgba(255,255,255,.12); border-radius:12px; left:80px; top:60px;
      font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang TC", "Microsoft JhengHei", Arial, sans-serif;
    }
    #ks-panel-header{ display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; cursor:move; }
    .ks-title{ font-size:14px; font-weight:700; }
    .ks-btn{ padding:6px 10px; border-radius:8px; border:1px solid rgba(255,255,255,.12); background:transparent; color:#eaeaea; cursor:pointer; font-size:12px; }
    .ks-btn:hover{ background:rgba(255,255,255,.08); }
    .ks-grid{ display:grid; grid-template-columns: 1fr 1fr; gap:10px 12px; }
    .ks-field{ display:flex; align-items:center; gap:8px; }
    .ks-field label{ font-size:12px; width:140px; opacity:.9; }
    .ks-field .ks-inline{ display:flex; gap:8px; align-items:center; }
    .ks-range{ width:120px; } .ks-row{ display:flex; align-items:center; justify-content:space-between; margin-top:8px; }
    .ks-note{ font-size:11px; opacity:.8; margin-top:6px; }
    .ks-switch{ width:42px; height:24px; border-radius:12px; background:#666; border:1px solid rgba(255,255,255,.12); position:relative; cursor:pointer; }
    .ks-switch::after{ content:""; position:absolute; top:2px; left:2px; width:18px; height:18px; border-radius:9px; background:#ddd; transition:left .15s ease, background .15s ease; }
    .ks-switch.on{ background:#2a7a2a; } .ks-switch.on::after{ left:22px; background:#fff; }
    select.ks-select{ padding:4px 8px; background:transparent; color:#eaeaea; border:1px solid rgba(255,255,255,.2); border-radius:8px; }
  `);

  // ---------- HUD ----------
  const visualizer = document.createElement('div'); visualizer.id='visualizer';
  visualizer.innerHTML = `
    <div class="row"><div class="key" id="key-w">W</div></div>
    <div class="row"><div class="key" id="key-a">A</div><div class="key" id="key-s">S</div><div class="key" id="key-d">D</div></div>
    <div class="row"><div class="key" id="key-lmb"><div id="lbl-lmb"></div><div class="cps" id="cps-lmb">0</div></div><div class="key" id="key-rmb"><div id="lbl-rmb"></div><div class="cps" id="cps-rmb">0</div></div></div>
    <div class="row"><div class="key" id="key-space">␣</div></div>
  `;
  document.body.appendChild(visualizer);

  // ---------- 面板 ----------
  const panel = buildPanel(); document.body.appendChild(panel);

  // ---------- 初始套用 ----------
  setPos(visualizer, S.hudPos); setPos(panel, S.panelPos);
  applyThemeVars(); applyScale(S.uiScale); applyLocale();
  visualizer.classList.toggle('ks-hidden', !S.show);
  panel.classList.add('ks-hidden');

  // ---------- 互動:拖曳 / 夾取 ----------
  enableDrag(visualizer, visualizer, (pos)=> save({ hudPos: pos }));
  enableDrag(panel, panel.querySelector('#ks-panel-header'), (pos)=> save({ panelPos: pos }));
  window.addEventListener('resize', ()=>{ clampToViewport(visualizer); clampToViewport(panel); });

  // ---------- 穩定縮放(Enter+滾輪) ----------
  let enterDown=false;
  window.addEventListener('keydown', (e)=>{ if(e.code==='Enter') enterDown=true; if(e.altKey && e.code==='KeyM'){ e.preventDefault(); panel.classList.toggle('ks-hidden'); } }, true);
  window.addEventListener('keyup',   (e)=>{ if(e.code==='Enter') enterDown=false; }, true);
  window.addEventListener('wheel', (e)=>{
    if(!enterDown) return;
    e.preventDefault();
    const step = 0.05 * (e.deltaY>0 ? -1 : 1);
    const next = clamp(parseFloat((S.uiScale + step).toFixed(2)), 0.7, 1.6);
    if (next !== S.uiScale) applyScaleStable(next);
  }, { passive:false });

  // ---------- 鍵盤 / 滑鼠 / CPS ----------
  const keySet = new Set(['KeyW','KeyA','KeyS','KeyD','Space']);
  window.addEventListener('keydown', (e)=>{ if(e.repeat) return; if(keySet.has(e.code)) setActive(e.code,true); }, true);
  window.addEventListener('keyup',   (e)=>{ if(keySet.has(e.code)) setActive(e.code,false); }, true);
  const clicksL=[]; const clicksR=[];
  window.addEventListener('mousedown',(e)=>{ if(e.button===0){ setActive('MouseLeft',true); clicksL.push(performance.now()); } if(e.button===2){ setActive('MouseRight',true); clicksR.push(performance.now()); } }, true);
  window.addEventListener('mouseup',  (e)=>{ if(e.button===0) setActive('MouseLeft',false); if(e.button===2) setActive('MouseRight',false); }, true);
  window.addEventListener('contextmenu', (e)=>{ e.preventDefault(); }, true);
  requestAnimationFrame(function loop(t){ updateCps(t); requestAnimationFrame(loop); });
  function prune(now){ const win=S.cpsWindowMs; while(clicksL.length&&now-clicksL[0]>win) clicksL.shift(); while(clicksR.length&&now-clicksR[0]>win) clicksR.shift(); }
  function updateCps(now){
    prune(now);
    const unit = t('cps');
    setText('cps-lmb', `${(clicksL.length/(S.cpsWindowMs/1000)).toFixed(0)} ${unit}`);
    setText('cps-rmb', `${(clicksR.length/(S.cpsWindowMs/1000)).toFixed(0)} ${unit}`);
  }

  // ---------- 面板構建 ----------
  function buildPanel(){
    const p = document.createElement('div'); p.id='ks-panel';
    p.innerHTML = `
      <div id="ks-panel-header"><div class="ks-title" id="i18n-title"></div><div><button class="ks-btn" id="ks-close"></button></div></div>

      <div class="ks-row">
        <div id="i18n-toggle"></div>
        <div id="ks-switch" class="ks-switch ${S.show?'on':''}"></div>
      </div>

      <div class="ks-field">
        <label id="i18n-language"></label>
        <div class="ks-inline">
          <select id="ks-lang" class="ks-select">
            <option value="auto">${T['zh-TW'].auto}</option>
            <option value="zh-TW">繁體中文</option>
            <option value="zh-CN">简体中文</option>
            <option value="en">English</option>
            <option value="ja">日本語</option>
            <option value="ko">한국어</option>
          </select>
        </div>
      </div>

      <div class="ks-grid">
        <div class="ks-field"><label id="i18n-base"></label><div class="ks-inline"><input id="baseColor" type="color" value="${S.baseColor}"><input id="baseAlpha" class="ks-range" type="range" min="0" max="1" step="0.01" value="${S.baseAlpha}"><span id="baseAlphaVal">${S.baseAlpha}</span></div></div>
        <div class="ks-field"><label id="i18n-active"></label><div class="ks-inline"><input id="activeColor" type="color" value="${S.activeColor}"><input id="activeAlpha" class="ks-range" type="range" min="0" max="1" step="0.01" value="${S.activeAlpha}"><span id="activeAlphaVal">${S.activeAlpha}</span></div></div>
        <div class="ks-field"><label id="i18n-outline"></label><div class="ks-inline"><input id="outlineColor" type="color" value="${S.outlineColor}"><input id="outlineAlpha" class="ks-range" type="range" min="0" max="1" step="0.01" value="${S.outlineAlpha}"><span id="outlineAlphaVal">${S.outlineAlpha}</span></div></div>
        <div class="ks-field"><label id="i18n-width"></label><div class="ks-inline"><input id="outlineWidth" class="ks-range" type="range" min="0" max="8" step="1" value="${S.outlineWidth}"><span id="outlineWidthVal">${S.outlineWidth}</span></div></div>
        <div class="ks-field"><label id="i18n-rounded"></label><div class="ks-inline"><div id="rounded" class="ks-switch ${S.rounded?'on':''}"></div></div></div>
        <div class="ks-field"><label id="i18n-radius"></label><div class="ks-inline"><input id="radius" class="ks-range" type="range" min="0" max="16" step="1" value="${S.radius}"><span id="radiusVal">${S.radius}</span></div></div>
      </div>

      <div class="ks-row"><div id="i18n-scale"></div><div id="scaleVal">${S.uiScale.toFixed(2)}</div></div>
      <div class="ks-row"><div></div><button class="ks-btn" id="resetPos"></button></div>
      <div class="ks-note" id="i18n-hint"></div>
    `;

    // 語言下拉
    p.querySelector('#ks-lang').value = S.lang;
    p.querySelector('#ks-lang').addEventListener('change', (e)=>{
      save({ lang: e.target.value });
      applyLocale();
    });

    // HUD 開關
    p.querySelector('#ks-switch').addEventListener('click', ()=>{
      const tgl = p.querySelector('#ks-switch'); const on = !tgl.classList.contains('on');
      tgl.classList.toggle('on', on);
      save({ show: on });
      visualizer.classList.toggle('ks-hidden', !on);
    });

    // 顏色/透明度
    bindColorAlpha(p,'baseColor','baseAlpha','baseAlphaVal', (color,alpha)=> saveAndApply({ baseColor:color, baseAlpha:alpha }));
    bindColorAlpha(p,'activeColor','activeAlpha','activeAlphaVal', (color,alpha)=> saveAndApply({ activeColor:color, activeAlpha:alpha }));
    bindColorAlpha(p,'outlineColor','outlineAlpha','outlineAlphaVal', (color,alpha)=> saveAndApply({ outlineColor:color, outlineAlpha:alpha }));

    // 外框粗細
    p.querySelector('#outlineWidth').addEventListener('input', (e)=>{
      const v = parseInt(e.target.value,10)||0; p.querySelector('#outlineWidthVal').textContent = v;
      saveAndApply({ outlineWidth: v });
    });

    // 圓角
    p.querySelector('#rounded').addEventListener('click', ()=>{
      const tgl = p.querySelector('#rounded'); const on = !tgl.classList.contains('on');
      tgl.classList.toggle('on', on);
      const rad = on ? S.radius : 0;
      saveAndApply({ rounded:on, radius:rad });
      p.querySelector('#radius').value = String(rad);
      p.querySelector('#radiusVal').textContent = rad;
    });
    p.querySelector('#radius').addEventListener('input', (e)=>{
      const v = parseInt(e.target.value,10)||0; p.querySelector('#radiusVal').textContent = v;
      saveAndApply({ radius: v });
    });

    // 重置位置
    p.querySelector('#resetPos').addEventListener('click', ()=>{
      save({ hudPos:{...DEF.hudPos}, panelPos:{...DEF.panelPos} });
      setPos(visualizer, S.hudPos); setPos(panel, S.panelPos);
      clampToViewport(visualizer); clampToViewport(panel);
    });

    // 關閉
    p.querySelector('#ks-close').addEventListener('click', ()=> p.classList.add('ks-hidden'));
    return p;
  }

  // ---------- 語系套用 ----------
  function applyLocale(){
    setTextContent('i18n-title', t('title'));
    setTextContent('ks-close', t('close'));
    setTextContent('i18n-toggle', t('toggle_keyboard'));
    setTextContent('i18n-language', t('language'));
    setTextContent('i18n-base', t('base_color'));
    setTextContent('i18n-active', t('active_color'));
    setTextContent('i18n-outline', t('outline_color'));
    setTextContent('i18n-width', t('outline_width'));
    setTextContent('i18n-rounded', t('rounded'));
    setTextContent('i18n-radius', t('radius'));
    setTextContent('i18n-scale', t('current_scale'));
    setTextContent('resetPos', t('reset_pos'));
    setTextContent('i18n-hint', t('hint'));
    setTextContent('lbl-lmb', t('lmb'));
    setTextContent('lbl-rmb', t('rmb'));
    updateScaleHint();
    // 更新下拉內 "Auto" 顯示文字
    const optAuto = panel.querySelector('#ks-lang option[value="auto"]');
    if (optAuto) optAuto.textContent = t('auto');
    // 更新 CPS 尾碼
    const unit = t('cps');
    setText('cps-lmb', withUnit(getNum('cps-lmb'), unit));
    setText('cps-rmb', withUnit(getNum('cps-rmb'), unit));
  }
  function withUnit(textOrNum, unit){
    const n = typeof textOrNum === 'number' ? textOrNum : parseFloat(String(textOrNum)) || 0;
    return `${n} ${unit}`;
  }
  function getNum(id){
    const el = document.getElementById(id);
    if (!el) return 0;
    const m = String(el.textContent||'').match(/[\d.]+/);
    return m ? parseFloat(m[0]) : 0;
  }

  // ---------- 穩定縮放 ----------
  function applyScaleStable(next){
    const before = visualizer.getBoundingClientRect();
    applyScale(next);
    const after = visualizer.getBoundingClientRect();
    const dx = after.left - before.left, dy = after.top - before.top;
    const curLeft = parseFloat(visualizer.style.left || '0');
    const curTop  = parseFloat(visualizer.style.top  || '0');
    visualizer.style.left = `${curLeft - dx}px`;
    visualizer.style.top  = `${curTop  - dy}px`;
    save({ uiScale: next });
    updateScaleHint();
    clampToViewport(visualizer);
  }
  function applyScale(v){ visualizer.style.transform = `scale(${v})`; }
  function updateScaleHint(){ const el = document.getElementById('scaleVal'); if (el) el.textContent = S.uiScale.toFixed(2); }

  // ---------- 主題 ----------
  function saveAndApply(patch){ save(patch); applyThemeVars(); }
  function applyThemeVars(){
    const root = document.documentElement.style;
    root.setProperty('--ks-bg', toRGBA(S.baseColor, S.baseAlpha));
    root.setProperty('--ks-active', toRGBA(S.activeColor, S.activeAlpha));
    root.setProperty('--ks-border-color', toRGBA(S.outlineColor, S.outlineAlpha));
    root.setProperty('--ks-border-width', `${S.outlineWidth}px`);
    root.setProperty('--ks-radius', `${S.rounded ? S.radius : 0}px`);
  }

  // ---------- 共用 ----------
  function setActive(code,on){
    const id = code==='Space' ? 'key-space' :
               code==='KeyW' ? 'key-w' :
               code==='KeyA' ? 'key-a' :
               code==='KeyS' ? 'key-s' :
               code==='KeyD' ? 'key-d' :
               code==='MouseLeft' ? 'key-lmb' : 'key-rmb';
    const n = document.getElementById(id); if(n) n.classList.toggle('active', !!on);
  }
  function bindColorAlpha(root, colorId, alphaId, labelId, onChange){
    const c = root.querySelector('#'+colorId), a = root.querySelector('#'+alphaId), l = root.querySelector('#'+labelId);
    const apply = ()=>{ const color=c.value; const alpha=parseFloat(a.value)||0; l.textContent = alpha.toFixed(2); onChange(color, alpha); };
    c.addEventListener('input', apply); a.addEventListener('input', apply);
  }
  function setText(id, text){ const n=document.getElementById(id); if(n) n.textContent=text; }
  function setTextContent(id, text){ const n=panel.querySelector('#'+id) || document.getElementById(id); if(n) n.textContent=text; }
  function setPos(el, pos){ if(!el||!pos) return; el.style.left=`${pos.x}px`; el.style.top=`${pos.y}px`; }
  function clampToViewport(node){
    if(!node) return; const r=node.getBoundingClientRect();
    const nx = clamp(r.left,0,Math.max(0,window.innerWidth - r.width));
    const ny = clamp(r.top ,0,Math.max(0,window.innerHeight- r.height));
    node.style.left=`${nx}px`; node.style.top=`${ny}px`;
  }
  function enableDrag(box, handle, onDone){
    let dragging=false,sx=0,sy=0,bx=0,by=0;
    handle.addEventListener('pointerdown',(e)=>{ if(e.button!==0) return; dragging=true; sx=e.clientX; sy=e.clientY; const rr=box.getBoundingClientRect(); bx=rr.left; by=rr.top; handle.setPointerCapture(e.pointerId); });
    handle.addEventListener('pointermove',(e)=>{ if(!dragging) return; const nx=clamp(bx+(e.clientX-sx),0,window.innerWidth - box.getBoundingClientRect().width); const ny=clamp(by+(e.clientY-sy),0,window.innerHeight - box.getBoundingClientRect().height); box.style.left=`${nx}px`; box.style.top=`${ny}px`; });
    handle.addEventListener('pointerup',()=>{ if(!dragging) return; dragging=false; const nx=parseFloat(box.style.left||'0'), ny=parseFloat(box.style.top||'0'); onDone?.({x:nx,y:ny}); });
  }
})();