wplace overlay script made by Furry Crew

Minimal overlay. No export/import. Camera lock infra kept without UI. GUI can be minimized.

// ==UserScript==
// @name         wplace overlay script made by Furry Crew
// @namespace    https://c11v.dev/userscripts/wplace-overlay
// @version      2.3.1
// @description  Minimal overlay. No export/import. Camera lock infra kept without UI. GUI can be minimized.
// @match        https://wplace.live/*
// @run-at       document-idle
// @inject-into  page
// @grant        none
// @license      GPL-3.0-or-later
// ==/UserScript==

(function () {
  // single-instance guard
  const KEY = "__WPO_SIMPLE_SINGLETON__";
  try { if (window[KEY]?.destroy) window[KEY].destroy(true); } catch {}
  const singleton = { destroy: ()=>{} };
  window[KEY] = singleton;

  const LS_KEY = "wplace.overlay.gui.simple.v1";
  const WM_TEXT_MAIN = "Made by Furry Crew";

  const log = (...a)=>console.log("[wplace-overlay]", ...a);
  const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));

  function toast(msg){
    const el=document.createElement("div");
    el.textContent=msg;
    el.style.cssText="position:fixed;left:50%;bottom:16px;transform:translateX(-50%);padding:8px 12px;background:rgba(0,0,0,.85);color:#fff;font:12px system-ui,sans-serif;border-radius:8px;z-index:2147483647";
    document.body.appendChild(el);
    setTimeout(()=>el.remove(),1200);
  }

  async function waitForBody(){
    if(document.body) return;
    await new Promise(r=>{
      const obs=new MutationObserver(()=>{
        if(document.body){ obs.disconnect(); r(); }
      });
      obs.observe(document.documentElement,{childList:true,subtree:true});
    });
  }

  let state = {
    on:true, x:0, y:0, scale:1, rot:0, opacity:0.25,
    imageInfo:{ w:0, h:0, name:"" },
    step:5,
    lock:false,
    minimized:false
  };
  try {
    const prev = JSON.parse(localStorage.getItem(LS_KEY)||"{}");
    Object.assign(state, prev);
  } catch {}

  function save(){ localStorage.setItem(LS_KEY, JSON.stringify(state)); }

  // overlay canvas
  let cvs=null, ctx=null, imgBitmap=null, iw=0, ih=0;
  function makeCanvas(){
    cvs=document.createElement("canvas");
    ctx=cvs.getContext("2d");
    Object.assign(cvs.style,{
      position:"fixed", top:"0", left:"0", width:"100vw", height:"100vh",
      pointerEvents:"none", zIndex:"2147483647"
    });
    cvs.width=window.innerWidth; cvs.height=window.innerHeight;
    const onResize=()=>{ cvs.width=innerWidth; cvs.height=innerHeight; draw(); };
    window.addEventListener("resize", onResize);
    cvs.__onResize = onResize;
    document.body.appendChild(cvs);
  }
  function clear(){ if(!ctx) return; ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,cvs.width,cvs.height); }

  function drawWatermark(){
    const pad = 10, baseSize = 14;
    ctx.save();
    ctx.imageSmoothingEnabled=true;
    ctx.globalAlpha = 0.9;
    ctx.font = `600 ${baseSize}px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
    ctx.textAlign = "left";
    ctx.textBaseline = "bottom";
    ctx.shadowColor = "rgba(0,0,0,0.7)";
    ctx.shadowBlur = 4;
    ctx.shadowOffsetY = 1;
    ctx.fillStyle = "white";
    ctx.fillText(WM_TEXT_MAIN, 10, cvs.height - pad);
    ctx.restore();
  }

  function draw(){
    if(!ctx) return;
    clear();
    if(imgBitmap && state.on){
      const cx=cvs.width/2, cy=cvs.height/2;
      ctx.save();
      ctx.globalAlpha=clamp(state.opacity,0,1);
      ctx.imageSmoothingEnabled=false;
      ctx.translate(cx+state.x, cy+state.y);
      ctx.rotate(state.rot*Math.PI/180);
      ctx.scale(state.scale, state.scale);
      ctx.drawImage(imgBitmap, -iw/2, -ih/2);
      ctx.restore();
    }
    drawWatermark();
  }

  async function loadFromFile(file){
    try{
      const bmp=await createImageBitmap(file);
      imgBitmap=bmp; iw=bmp.width; ih=bmp.height;
      state.imageInfo={ w:iw, h:ih, name:file.name||"" };
      draw(); refreshFooter();
      toast(`Loaded ${file.name} ${iw}×${ih}`);
      save();
    }catch{ toast("Failed to load file"); }
  }
  function chooseImageFile(){
    const inp=document.createElement("input");
    inp.type="file";
    inp.accept="image/png,image/jpeg,image/webp,image/gif";
    inp.onchange=()=>{ const f=inp.files && inp.files[0]; if(f) loadFromFile(f); };
    inp.click();
  }

  // camera lock infra kept without UI
  let isLeftDown = false;
  const lockProxy = document.createElement("div");
  Object.assign(lockProxy.style, {
    position:"fixed", inset:"0", zIndex:"2147483646", pointerEvents:"none"
  });
  lockProxy.onclick = (e)=>{
    const target = document.elementFromPoint(e.clientX, e.clientY);
    if(target && target !== lockProxy){
      const evt = new MouseEvent("click", {bubbles:true, cancelable:true, clientX:e.clientX, clientY:e.clientY, button:0});
      target.dispatchEvent(evt);
    }
  };
  ["mousedown","mousemove","mouseup","wheel","contextmenu","touchstart","touchmove","touchend"].forEach(type=>{
    lockProxy.addEventListener(type, ev=>{
      ev.stopImmediatePropagation();
      ev.preventDefault();
    }, {passive:false});
  });

  function applyLockSideEffects(){
    lockProxy.style.pointerEvents = state.lock ? "auto" : "none";
  }

  function installLockHandlers(){
    window.addEventListener("wheel", (e)=>{
      if(!state.lock) return;
      e.stopImmediatePropagation();
      e.preventDefault();
    }, {capture:true, passive:false});

    window.addEventListener("mousedown", (e)=>{
      if(!state.lock) return;
      isLeftDown = e.button === 0;
      e.stopImmediatePropagation();
      e.preventDefault();
    }, {capture:true});

    window.addEventListener("mousemove", (e)=>{
      if(!state.lock) return;
      if(isLeftDown){
        e.stopImmediatePropagation();
        e.preventDefault();
      }
    }, {capture:true});

    window.addEventListener("mouseup", (e)=>{
      if(!state.lock) return;
      isLeftDown = false;
      e.stopImmediatePropagation();
      e.preventDefault();
    }, {capture:true});

    window.addEventListener("touchstart", (e)=>{
      if(!state.lock) return;
      e.stopImmediatePropagation();
      e.preventDefault();
    }, {capture:true, passive:false});

    window.addEventListener("touchmove", (e)=>{
      if(!state.lock) return;
      e.stopImmediatePropagation();
      e.preventDefault();
    }, {capture:true, passive:false});

    window.addEventListener("keydown", (e)=>{
      if(!state.lock) return;
      const k = e.key.toLowerCase();
      const block = new Set(["w","a","s","d","arrowup","arrowdown","arrowleft","arrowright","+","-","=","_"," ","pageup","pagedown","home","end"]);
      if(block.has(k) || (e.ctrlKey && (k==="+" || k==="=" || k==="-" ))){
        e.stopImmediatePropagation();
        e.preventDefault();
      }
    }, {capture:true});
  }
  installLockHandlers();

  // GUI
  let guiHost=null, shadow=null, keepAliveId=null;
  function buildGUI(){
    guiHost=document.createElement("div");
    shadow=guiHost.attachShadow({mode:"open"});

    const style=document.createElement("style");
    style.textContent=`
      :host { all: initial; }
      *, *::before, *::after { box-sizing: border-box; }
      .panel{
        position:fixed; top:16px; right:16px;
        background:#0f0f10; color:#eaeaea; font:12px system-ui,sans-serif;
        border:1px solid #222; border-radius:12px; padding:12px; z-index:2147483647;
        box-shadow:0 12px 24px rgba(0,0,0,.4); user-select:none;
        width:auto; max-width:min(420px, calc(100vw - 32px));
        max-height:calc(100vh - 32px); overflow:auto;
      }
      .title{ display:flex; align-items:center; justify-content:space-between; font-weight:700; margin-bottom:8px; cursor:move; gap:8px; }
      .title-left{ display:flex; align-items:center; gap:8px; }
      .sublabel{ color:#a9a9a9; font-size:10px; white-space:nowrap; }
      .title-btn{
        margin-left:auto; background:#171718; color:#eaeaea; border:1px solid #2d2d2f; border-radius:8px;
        padding:4px 8px; cursor:pointer; font:12px system-ui,sans-serif;
      }
      .btns{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:6px; }
      .row{ display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; margin:10px 0; }
      .controls{ display:grid; grid-template-columns: 1fr minmax(68px,92px); gap:8px; grid-column:1 / span 2; align-items:center; }
      input[type="number"]{
        width:100%; min-width:68px; background:#171718; color:#eaeaea; border:1px solid #2d2d2f; border-radius:8px;
        padding:6px 8px; font:12px system-ui,sans-serif;
      }
      input[type="range"]{ width:100%; }
      button,label{
        background:#171718; color:#eaeaea; border:1px solid #2d2d2f; border-radius:8px;
        padding:6px 8px; cursor:pointer; font:12px system-ui,sans-serif;
      }
      button:active{ transform:translateY(1px); }
      .mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
      .footer{
        margin-top:8px; border-top:1px solid #1d1d1f; padding-top:6px;
        display:flex; justify-content:space-between; align-items:center; color:#a6a6a6; font-size:10px;
        gap:8px; flex-wrap:wrap;
      }
      .footer .mono { white-space:nowrap; }
      .dpad{
        display:grid; grid-template-columns:repeat(3,32px); grid-template-rows:repeat(3,32px);
        gap:6px;
      }
      .dpad button{ padding:0; height:32px; width:32px; }
      .dpad .spacer{ visibility:hidden; }
      .min-note{ color:#a6a6a6; font-size:10px; }
      @media (max-width: 420px){
        .controls{ grid-template-columns: 1fr minmax(60px,80px); }
        .dpad{ grid-template-columns:repeat(3,28px); grid-template-rows:repeat(3,28px); }
        .dpad button{ width:28px; height:28px; }
      }
    `;

    const wrap=document.createElement("div");
    wrap.innerHTML=`
      <div class="panel" id="panel" aria-live="polite">
        <div class="title" id="drag">
          <div class="title-left">
            <span>Overlay</span>
            <span class="sublabel">${WM_TEXT_MAIN}</span>
          </div>
          <button id="min" class="title-btn" title="Minimize">▾</button>
        </div>

        <div id="content">
          <div class="btns">
            <button id="load">Load image</button>
            <button id="toggle">${state.on?"Hide":"Show"}</button>
            <button id="save">Save</button>
            <button id="reset">Reset</button>
          </div>

          <div class="row">
            <label>Opacity</label>
            <div class="controls">
              <input class="range" id="opacity" type="range" min="0" max="1" step="0.05" value="${state.opacity}">
              <input class="mono" id="opacity_n" type="number" min="0" max="1" step="0.01" value="${state.opacity.toFixed(2)}">
            </div>
          </div>

          <div class="row">
            <label>Scale</label>
            <div class="controls">
              <input class="range" id="scale" type="range" min="0.01" max="10" step="0.01" value="${state.scale}">
              <input class="mono" id="scale_n" type="number" min="0.01" max="10" step="0.001" value="${state.scale.toFixed(3)}">
            </div>
          </div>

          <div class="row">
            <label>Rotation</label>
            <div class="controls">
              <input class="range" id="rot" type="range" min="-180" max="180" step="1" value="${state.rot}">
              <input class="mono" id="rot_n" type="number" min="-180" max="180" step="1" value="${state.rot.toFixed(0)}">
            </div>
          </div>

          <div class="row">
            <label>Position</label>
            <div class="row" style="grid-column:1 / span 2; grid-template-columns:auto 1fr; align-items:center;">
              <div class="dpad">
                <span class="spacer"></span>
                <button id="up" title="Up">↑</button>
                <span class="spacer"></span>

                <button id="left" title="Left">←</button>
                <button id="center" title="Center">•</button>
                <button id="right" title="Right">→</button>

                <span class="spacer"></span>
                <button id="down" title="Down">↓</button>
                <span class="spacer"></span>
              </div>
              <div style="justify-self:end; display:flex; align-items:center; gap:8px;">
                <span>Step</span>
                <input class="mono" id="step_n" type="number" min="1" max="200" step="1" value="${state.step}">
              </div>
            </div>
          </div>

          <div class="footer">
            <span id="imgmeta">${state.imageInfo?.name ? `${state.imageInfo.name} ${state.imageInfo.w}×${state.imageInfo.h}` : "No image loaded"}</span>
            <span class="mono" id="scale_meta">${state.scale.toFixed(3)}×</span>
          </div>
        </div>
        <div id="minnote" class="min-note" style="display:none;">Minimized. Click ▸ to expand</div>
      </div>
    `;

    shadow.append(style, wrap);
    document.body.appendChild(guiHost);

    const $$=(id)=>shadow.getElementById(id);

    // drag panel
    const panel=$$("panel");
    const drag=$$("drag");
    let dx=0, dy=0, dragging=false;
    drag.addEventListener("mousedown", e=>{
      dragging=true;
      const r=panel.getBoundingClientRect();
      dx=e.clientX-r.left; dy=e.clientY-r.top; e.preventDefault();
    });
    window.addEventListener("mousemove", e=>{
      if(!dragging) return;
      panel.style.left=(e.clientX-dx)+"px";
      panel.style.top=(e.clientY-dy)+"px";
      panel.style.right="auto";
    });
    window.addEventListener("mouseup", ()=>dragging=false);

    // minimize toggle
    const content = $$("content");
    const minBtn = $$("min");
    const minNote = $$("minnote");
    function applyMinimized(){
      if(state.minimized){
        content.style.display="none";
        minNote.style.display="block";
        minBtn.textContent="▸";
        minBtn.title="Expand";
      }else{
        content.style.display="";
        minNote.style.display="none";
        minBtn.textContent="▾";
        minBtn.title="Minimize";
      }
    }
    minBtn.addEventListener("click", ()=>{
      state.minimized = !state.minimized;
      applyMinimized();
      save();
    });
    // also allow double click on title bar
    drag.addEventListener("dblclick", ()=>{
      state.minimized = !state.minimized;
      applyMinimized();
      save();
    });
    applyMinimized();

    // buttons
    $$("load").addEventListener("click", chooseImageFile);
    $$("toggle").addEventListener("click", ()=>{ state.on=!state.on; $$("toggle").textContent=state.on?"Hide":"Show"; draw(); save(); });
    $$("save").addEventListener("click", ()=>{ save(); toast("Saved"); });
    $$("reset").addEventListener("click", ()=>{
      Object.assign(state,{x:0,y:0,scale:1,rot:0});
      $$("scale").value=state.scale; $$("scale_n").value=state.scale.toFixed(3);
      $$("rot").value=state.rot; $$("rot_n").value=state.rot.toFixed(0);
      draw(); refreshFooter(); save();
    });

    // sliders
    const syncRangeNum = (rangeId, numId, onChange) => {
      const r=$$(rangeId), n=$$(numId);
      const fmt = (id,val)=>{
        if(id.includes("scale")) return val.toFixed(3);
        if(id.includes("opacity")) return val.toFixed(2);
        if(id.includes("rot")) return val.toFixed(0);
        return String(val);
      };
      const clampTo = (v,min,max)=>Math.min(max,Math.max(min,v));
      r.addEventListener("input", ()=>{ const v=parseFloat(r.value); n.value=fmt(numId, v); onChange(v); save(); });
      n.addEventListener("change", ()=>{ let v=parseFloat(n.value); if(isNaN(v)) return; let min=parseFloat(r.min), max=parseFloat(r.max); v = clampTo(v,min,max); r.value=String(v); n.value=fmt(numId, v); onChange(v); save(); });
    };

    syncRangeNum("opacity","opacity_n",(v)=>{ state.opacity=v; draw(); });
    syncRangeNum("scale","scale_n",(v)=>{ state.scale=v; draw(); refreshFooter(); });
    syncRangeNum("rot","rot_n",(v)=>{ state.rot=v; draw(); });

    $$("step_n").addEventListener("change", ()=>{
      const v = parseInt($$("step_n").value,10);
      if(!isNaN(v) && v>=1 && v<=200){ state.step=v; save(); }
      $$("step_n").value = String(state.step);
    });

    // dpad
    const move = (dx,dy)=>{ state.x += dx; state.y += dy; draw(); save(); };
    const hold = (el,fn)=>{
      let t, rep;
      const start=()=>{ fn(); t=setTimeout(()=>{ rep=setInterval(fn, 40); }, 300); };
      const stop=()=>{ clearTimeout(t); clearInterval(rep); };
      el.addEventListener("mousedown", start);
      el.addEventListener("mouseup", stop);
      el.addEventListener("mouseleave", stop);
      el.addEventListener("touchstart", e=>{ e.preventDefault(); start(); }, {passive:false});
      el.addEventListener("touchend", stop);
    };
    hold($$("up"),   ()=>move(0, -state.step));
    hold($$("down"), ()=>move(0,  state.step));
    hold($$("left"), ()=>move(-state.step, 0));
    hold($$("right"),()=>move( state.step, 0));
    $$("center").addEventListener("click", ()=>{ state.x=0; state.y=0; draw(); save(); });

    refreshFooter();
  }

  function refreshFooter(){
    try{
      const scaleMeta = shadow?.getElementById("scale_meta");
      if(scaleMeta) scaleMeta.textContent = `${state.scale.toFixed(3)}×`;
      const meta = shadow?.getElementById("imgmeta");
      if(meta) meta.textContent = state.imageInfo?.name ? `${state.imageInfo.name} ${state.imageInfo.w}×${state.imageInfo.h}` : "No image loaded";
    }catch{}
  }

  function applyState(next){
    Object.assign(state, next || {});
    const $ = (id)=>shadow?.getElementById(id);
    if($){
      const s=$("scale"), sn=$("scale_n");
      if(s){ s.value = String(state.scale); }
      if(sn){ sn.value = state.scale.toFixed(3); }
      const r=$("rot"), rn=$("rot_n");
      if(r){ r.value = String(state.rot); }
      if(rn){ rn.value = state.rot.toFixed(0); }
      const op=$("opacity"), opn=$("opacity_n");
      if(op){ op.value = String(state.opacity); }
      if(opn){ opn.value = state.opacity.toFixed(2); }
      const step=$("step_n"); if(step) step.value = String(state.step);
      const tog=$("toggle"); if(tog) tog.textContent = state.on?"Hide":"Show";
      const minBtn = $("min"); if(minBtn){ /* handled by applyMinimized during build */ }
      refreshFooter();
    }
    applyLockSideEffects();
    draw();
  }

  function keepAlive(){
    const ensure=()=>{
      if(!document.body) return;
      if(cvs && !cvs.isConnected){ document.body.appendChild(cvs); }
      if(guiHost && !guiHost.isConnected){ document.body.appendChild(guiHost); }
      if(lockProxy && !lockProxy.isConnected){ document.body.appendChild(lockProxy); }
    };
    return setInterval(ensure, 1000);
  }

  // cleanup
  singleton.destroy = function destroy(fromPrev){
    try{ clearInterval(keepAliveId); }catch{}
    try{
      if(cvs){ window.removeEventListener("resize", cvs.__onResize || (()=>{})); cvs.remove(); }
      if(guiHost) guiHost.remove();
      if(lockProxy) lockProxy.remove();
    }catch{}
    if(!fromPrev) toast("Overlay removed");
  };

  // tiny API
  singleton.setLock = function(v){
    state.lock = !!v;
    applyLockSideEffects();
    save();
    toast(state.lock ? "Camera locked" : "Camera unlocked");
  };

  (async function init(){
    await waitForBody();
    document.body.appendChild(lockProxy); // keep under canvas
    makeCanvas();
    buildGUI();
    keepAliveId = keepAlive();
    applyLockSideEffects();

    document.addEventListener("paste", async e=>{
      const items=e.clipboardData?.items||[];
      for(const it of items){
        if(it.type && it.type.startsWith("image/")){
          const f=it.getAsFile(); if(f) await loadFromFile(f);
          e.preventDefault(); break;
        }
      }
    }, true);

    log("wplace overlay ready");
    toast("Overlay ready. Load image to begin");
  })();
})();