(function () { const { Engine, World, Bodies, Body, Mouse, MouseConstraint, Composite, Sleeping, } = Matter; // Public API: window.initFidgetBox(imageUrl, options?) window.initFidgetBox = function initFidgetBox(imageUrl, opts = {}) { if (!imageUrl) { console.warn("initFidgetBox: imageUrl is required"); return; } if (document.getElementById("fidget-overlay")) return; const { size = 150, width = size, height = size, corner = "bottom-right", // 'bottom-right' | 'bottom-left' zIndex = 2147483640, peek = 24, // default peek in px peekX = null, // overrides peek if provided peekY = null, // overrides peek if provided gravity = 0.8, } = opts; const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const boxWidth = Math.max(40, Number(width) || size); const boxHeight = Math.max(40, Number(height) || size); const px = clamp(peekX ?? peek, 0, boxWidth); const py = clamp(peekY ?? peek, 0, boxHeight); // Overlay that doesn't eat page clicks (only the box is interactive) const overlay = document.createElement("div"); overlay.id = "fidget-overlay"; Object.assign(overlay.style, { position: "fixed", inset: "0", width: "100vw", height: "100vh", overflow: "hidden", pointerEvents: "none", zIndex: String(zIndex), }); document.body.appendChild(overlay); // Capture layer (full-viewport parent for the box) // IMPORTANT: MouseConstraint listens on mouse.element; making this the parent // ensures pointer/mouse events from the box bubble to it. const capture = document.createElement("div"); capture.id = "fidget-capture"; Object.assign(capture.style, { position: "fixed", inset: "0", width: "100vw", height: "100vh", pointerEvents: "none", // enabled only while dragging overflow: "hidden", }); overlay.appendChild(capture); const engine = Engine.create({ enableSleeping: false }); engine.gravity.y = gravity; // Use clientWidth/Height to better match the visual viewport (helps on ultrawide + scrollbar scenarios) const getViewport = () => ({ w: document.documentElement.clientWidth || window.innerWidth, h: document.documentElement.clientHeight || window.innerHeight, }); // Walls at viewport edges (thick + extended to avoid tunneling, especially on ultrawide) function makeWalls(w, h) { const T = Math.max(100, Math.ceil(Math.max(w, h) * 0.04)); const half = T / 2; const S = { isStatic: true, restitution: 0.8, friction: 1 }; const floor = Bodies.rectangle(w / 2, h + half, w + 2 * T, T, S); const ceil = Bodies.rectangle(w / 2, -half, w + 2 * T, T, S); const left = Bodies.rectangle(-half, h / 2, T, h + 2 * T, S); const right = Bodies.rectangle(w + half, h / 2, T, h + 2 * T, S); return [floor, ceil, left, right]; } let { w: W, h: H } = getViewport(); World.add(engine.world, makeWalls(W, H)); // Correct initial center so exactly px/py pixels are visible in the chosen corner // Visible px means the box is mostly off-screen: center is shifted OUT by (size/2 - px). const initialX = corner === "bottom-left" ? -boxWidth / 2 + px : W + boxWidth / 2 - px; const initialY = H + boxHeight / 2 - py; const boxBody = Bodies.rectangle(initialX, initialY, boxWidth, boxHeight, { restitution: 0.75, friction: 0.1, frictionAir: 0.01, }); World.add(engine.world, boxBody); // Mouse/dragging: // Create the mouse on the CAPTURE element (not body). This avoids body rect offsets // on centered layouts and ensures events bubble correctly. const mouse = Mouse.create(capture); // We are using CSS pixels (DOM transform) rather than a DPR-scaled canvas, // so keep pixelRatio=1 to avoid coordinate drift. mouse.pixelRatio = 1; const mouseConstraint = MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.25, angularStiffness: 0.25, render: { visible: false }, }, }); World.add(engine.world, mouseConstraint); // Visual element (child of capture so events bubble to capture) const el = document.createElement("div"); el.className = "fidget-box"; Object.assign(el.style, { position: "absolute", width: `${boxWidth}px`, height: `${boxHeight}px`, backgroundImage: `url("${String(imageUrl).replace(/"/g, '\\"')}")`, backgroundSize: "contain", backgroundRepeat: "no-repeat", backgroundPosition: "center", willChange: "transform", pointerEvents: "auto", touchAction: "none", userSelect: "none", WebkitUserSelect: "none", // Small QoL: avoid long-press callouts on iOS WebkitTouchCallout: "none", }); capture.appendChild(el); // Enable capture only while dragging so the rest of the page stays clickable const enableCapture = () => (capture.style.pointerEvents = "auto"); const disableCapture = () => (capture.style.pointerEvents = "none"); // Wake + pointer capture for robust dragging (uses correct sleeping API) el.addEventListener( "pointerdown", (e) => { enableCapture(); if (el.setPointerCapture && e.pointerId != null) { try { el.setPointerCapture(e.pointerId); } catch {} } if (boxBody.isSleeping) Sleeping.set(boxBody, false); // Tiny nudge to ensure constraint response immediately Body.applyForce(boxBody, boxBody.position, { x: 0.00008, y: -0.00008 }); }, { passive: true } ); window.addEventListener("pointerup", disableCapture, { passive: true }); window.addEventListener("pointercancel", disableCapture, { passive: true }); // Animation loop let raf = null; (function tick() { Engine.update(engine, 1000 / 60); const p = boxBody.position; el.style.transform = `translate(-50%, -50%) translate(${p.x}px, ${p.y}px) rotate(${boxBody.angle}rad)`; raf = requestAnimationFrame(tick); })(); // Resize: rebuild walls and clamp body within viewport padding let resizeTimer = null; function onResize() { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const vp = getViewport(); const w = vp.w; const h = vp.h; if (w === W && h === H) return; W = w; H = h; const statics = Composite.allBodies(engine.world).filter((b) => b.isStatic); World.remove(engine.world, statics); World.add(engine.world, makeWalls(W, H)); const nx = clamp(boxBody.position.x, -boxWidth, W + boxWidth); const ny = clamp(boxBody.position.y, -boxHeight, H + boxHeight); Body.setPosition(boxBody, { x: nx, y: ny }); if (boxBody.isSleeping) Sleeping.set(boxBody, false); }, 100); } window.addEventListener("resize", onResize, { passive: true }); window.addEventListener("orientationchange", onResize, { passive: true }); window.resetFidgetBoxToCenter = function resetFidgetBoxToCenter() { const vp = getViewport(); Body.setPosition(boxBody, { x: vp.w / 2, y: vp.h / 2 }); Body.setVelocity(boxBody, { x: 0, y: 0 }); Body.setAngularVelocity(boxBody, 0); Body.setAngle(boxBody, 0); if (boxBody.isSleeping) Sleeping.set(boxBody, false); }; // Cleanup window.destroyFidgetBox = function destroyFidgetBox() { window.removeEventListener("resize", onResize); window.removeEventListener("orientationchange", onResize); window.removeEventListener("pointerup", disableCapture); window.removeEventListener("pointercancel", disableCapture); delete window.resetFidgetBoxToCenter; if (raf) cancelAnimationFrame(raf); World.clear(engine.world, false); Engine.clear(engine); overlay.remove(); }; }; })();