Wplace Overlay Pro

Manage multiple sharable overlays (with import/export), render them behind tiles on wplace.live.

目前為 2025-08-09 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Wplace Overlay Pro
// @namespace    http://tampermonkey.net/
// @version      2.0.1
// @description  Manage multiple sharable overlays (with import/export), render them behind tiles on wplace.live.
// @author       shinkonet
// @match        https://wplace.live/*
// @license      MIT
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      *
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // ---------- Constants ----------
  const TILE_SIZE = 1000;

  // ---------- GM helpers (support GM.* and GM_*) ----------
  const gmGet = (key, def) => {
    try {
      if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') return GM.getValue(key, def);
      if (typeof GM_getValue === 'function') return Promise.resolve(GM_getValue(key, def));
    } catch {}
    return Promise.resolve(def);
  };
  const gmSet = (key, value) => {
    try {
      if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') return GM.setValue(key, value);
      if (typeof GM_setValue === 'function') return Promise.resolve(GM_setValue(key, value));
    } catch {}
    return Promise.resolve();
  };

  // Cross-origin blob fetch via GM_xmlhttpRequest
  function gmFetchBlob(url) {
    return new Promise((resolve, reject) => {
      try {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          responseType: 'blob',
          onload: (res) => {
            if (res.status >= 200 && res.status < 300 && res.response) {
              resolve(res.response);
            } else {
              reject(new Error(`GM_xhr failed: ${res.status} ${res.statusText}`));
            }
          },
          onerror: (e) => reject(new Error('GM_xhr network error')),
          ontimeout: () => reject(new Error('GM_xhr timeout')),
        });
      } catch (e) {
        reject(e);
      }
    });
  }
  function blobToDataURL(blob) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload = () => resolve(fr.result);
      fr.onerror = reject;
      fr.readAsDataURL(blob);
    });
  }
  async function urlToDataURL(url) {
    const blob = await gmFetchBlob(url);
    if (!blob || !String(blob.type).startsWith('image/')) {
      throw new Error('URL did not return an image blob');
    }
    return await blobToDataURL(blob);
  }

  const config = {
    overlays: [],
    activeOverlayId: null,
    overlayMode: 'overlay',
    isPanelCollapsed: false,
    autoCapturePixelUrl: false
  };
  const CONFIG_KEYS = Object.keys(config);

  async function loadConfig() {
    try {
      await Promise.all(CONFIG_KEYS.map(async k => { config[k] = await gmGet(k, config[k]); }));
    } catch (e) {
      console.error("Overlay Pro: Failed to load config", e);
    }
  }
  async function saveConfig(keys = CONFIG_KEYS) {
    try {
      await Promise.all(keys.map(k => gmSet(k, config[k])));
    } catch (e) {
      console.error("Overlay Pro: Failed to save config", e);
    }
  }

  const page = unsafeWindow;

  function uid() {
    return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
  }

  function createCanvas(w, h) {
    if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
    const c = document.createElement('canvas'); c.width = w; c.height = h; return c;
  }
  function canvasToBlob(canvas) {
    if (canvas.convertToBlob) return canvas.convertToBlob(); // OffscreenCanvas
    return new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error("toBlob failed")), "image/png"));
  }
  async function blobToImage(blob) {
    if (typeof createImageBitmap === 'function') {
      try { return await createImageBitmap(blob); } catch {/* fallback below */}
    }
    return new Promise((resolve, reject) => {
      const url = URL.createObjectURL(blob);
      const img = new Image();
      img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
      img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
      img.src = url;
    });
  }
  function loadImage(src) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.onload = () => resolve(img);
      img.onerror = reject;
      img.src = src;
    });
  }
  function extractPixelCoords(pixelUrl) {
    try {
      const u = new URL(pixelUrl);
      const parts = u.pathname.split('/');
      const sp = new URLSearchParams(u.search);
      return {
        chunk1: parseInt(parts[3], 10),
        chunk2: parseInt(parts[4], 10),
        posX: parseInt(sp.get('x') || '0', 10),
        posY: parseInt(sp.get('y') || '0', 10),
      };
    } catch {
      return { chunk1: 0, chunk2: 0, posX: 0, posY: 0 };
    }
  }
  function matchTileUrl(urlStr) {
    try {
      const u = new URL(urlStr, location.href);
      if (u.hostname !== 'backend.wplace.live' || !u.pathname.startsWith('/files/')) return null;
      const m = u.pathname.match(/\/(\d+)\/(\d+)\.png$/i);
      if (!m) return null;
      return { chunk1: parseInt(m[1], 10), chunk2: parseInt(m[2], 10) };
    } catch { return null; }
  }
  function matchPixelUrl(urlStr) {
    try {
      const u = new URL(urlStr, location.href);
      if (u.hostname !== 'backend.wplace.live') return null;
      const m = u.pathname.match(/\/s0\/pixel\/(\d+)\/(\d+)$/);
      if (!m) return null;
      const sp = u.searchParams;
      return {
        normalized: `https://backend.wplace.live/s0/pixel/${m[1]}/${m[2]}?x=${sp.get('x')||0}&y=${sp.get('y')||0}`
      };
    } catch { return null; }
  }
  function rectIntersect(ax, ay, aw, ah, bx, by, bw, bh) {
    const x = Math.max(ax, bx);
    const y = Math.max(ay, by);
    const r = Math.min(ax + aw, bx + bw);
    const b = Math.min(ay + ah, by + bh);
    const w = Math.max(0, r - x);
    const h = Math.max(0, b - y);
    return { x, y, w, h };
  }

  const overlayCache = new Map();
  function overlaySignature(ov) {
    const imgKey = ov.imageBase64 ? ov.imageBase64.slice(0, 64) + ':' + ov.imageBase64.length : 'none';
    return [imgKey, ov.pixelUrl || 'null', ov.offsetX, ov.offsetY, ov.opacity].join('|');
  }
  function clearOverlayCache() {
    overlayCache.clear();
  }

  async function buildOverlayDataForChunk(ov, targetChunk1, targetChunk2) {
    if (!ov.enabled || !ov.imageBase64 || !ov.pixelUrl) return null;

    const sig = overlaySignature(ov);
    const cacheKey = `${ov.id}|${sig}|${targetChunk1}|${targetChunk2}`;
    if (overlayCache.has(cacheKey)) return overlayCache.get(cacheKey);

    const img = await loadImage(ov.imageBase64);
    if (!img) return null;

    const base = extractPixelCoords(ov.pixelUrl);
    if (!Number.isFinite(base.chunk1) || !Number.isFinite(base.chunk2)) return null;

    const drawX = (base.chunk1 * TILE_SIZE + base.posX + ov.offsetX) - (targetChunk1 * TILE_SIZE);
    const drawY = (base.chunk2 * TILE_SIZE + base.posY + ov.offsetY) - (targetChunk2 * TILE_SIZE);

    const isect = rectIntersect(0, 0, TILE_SIZE, TILE_SIZE, drawX, drawY, img.width, img.height);
    if (isect.w === 0 || isect.h === 0) {
      overlayCache.set(cacheKey, null);
      return null;
    }

    const canvas = createCanvas(TILE_SIZE, TILE_SIZE);
    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, drawX, drawY);

    const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h);
    const data = imageData.data;
    const colorStrength = ov.opacity;
    const whiteStrength = 1 - colorStrength;

    for (let i = 0; i < data.length; i += 4) {
      if (data[i + 3] > 0) {
        data[i    ] = Math.round(data[i    ] * colorStrength + 255 * whiteStrength);
        data[i + 1] = Math.round(data[i + 1] * colorStrength + 255 * whiteStrength);
        data[i + 2] = Math.round(data[i + 2] * colorStrength + 255 * whiteStrength);
        data[i + 3] = 255;
      }
    }

    const result = { imageData, dx: isect.x, dy: isect.y };
    overlayCache.set(cacheKey, result);
    return result;
  }

  async function mergeOverlaysBehind(originalBlob, overlayDatas) {
    if (!overlayDatas || overlayDatas.length === 0) return originalBlob;

    const originalImage = await blobToImage(originalBlob);
    const w = originalImage.width;
    const h = originalImage.height;

    const canvas = createCanvas(w, h);
    const ctx = canvas.getContext('2d');

    for (const ovd of overlayDatas) {
      if (!ovd) continue;
      ctx.putImageData(ovd.imageData, ovd.dx, ovd.dy);
    }
    ctx.drawImage(originalImage, 0, 0);

    return await canvasToBlob(canvas);
  }

  function hookFetch() {
    const originalFetch = page.fetch;
    if (!originalFetch || originalFetch.__overlayHooked) return;

    const hookedFetch = async (input, init) => {
      const urlStr = typeof input === 'string' ? input : (input && input.url) || '';

      if (config.autoCapturePixelUrl && config.activeOverlayId) {
        const pixelMatch = matchPixelUrl(urlStr);
        if (pixelMatch) {
          const ov = config.overlays.find(o => o.id === config.activeOverlayId);
          if (ov) {
            if (ov.pixelUrl !== pixelMatch.normalized) {
              ov.pixelUrl = pixelMatch.normalized;
              await saveConfig(['overlays']);
              clearOverlayCache();
              updateUI();
            }
          }
        }
      }

      const tileMatch = matchTileUrl(urlStr);
      if (!tileMatch || config.overlayMode !== 'overlay') {
        return originalFetch(input, init);
      }

      try {
        const response = await originalFetch(input, init);
        if (!response.ok) return response;

        const enabledOverlays = config.overlays.filter(o => o.enabled && o.imageBase64 && o.pixelUrl);
        if (enabledOverlays.length === 0) return response;

        const originalBlob = await response.blob();

        const overlayDatas = [];
        for (const ov of enabledOverlays) {
          overlayDatas.push(await buildOverlayDataForChunk(ov, tileMatch.chunk1, tileMatch.chunk2));
        }
        const mergedBlob = await mergeOverlaysBehind(originalBlob, overlayDatas.filter(Boolean));

        const headers = new Headers(response.headers);
        headers.set('Content-Type', 'image/png');
        headers.delete('Content-Length');

        return new Response(mergedBlob, {
          status: response.status,
          statusText: response.statusText,
          headers
        });
      } catch (e) {
        console.error("Overlay Pro: Error processing tile", e);
        return originalFetch(input, init);
      }
    };

    hookedFetch.__overlayHooked = true;
    page.fetch = hookedFetch;
    window.fetch = hookedFetch;
    console.log('Overlay Pro: Fetch hook installed.');
  }

  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = `
      #overlay-pro-panel {
        position: fixed;
        top: 272px;
        right: 12px;
        z-index: 2;
        background: rgba(20,20,20,0.9);
        backdrop-filter: blur(8px);
        border: 1px solid #444;
        border-radius: 8px;
        color: white;
        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
        font-size: 14px;
        width: 320px;
      }
      .op-header { padding: 8px 12px; background: #333; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-top-left-radius: 8px; border-top-right-radius: 8px; }
      .op-header h3 { margin: 0; font-size: 16px; user-select: none; }
      .op-toggle-btn { font-size: 20px; background: none; border: none; color: white; cursor: pointer; padding: 0 5px; }
      .op-content { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
      .op-section { display: flex; flex-direction: column; gap: 8px; }
      .op-row { display: flex; align-items: center; gap: 8px; }
      .op-button { background: #555; color: white; border: 1px solid #777; border-radius: 4px; padding: 6px 10px; cursor: pointer; }
      .op-button:hover { background: #666; }
      .op-button.danger { background: #7a2b2b; border-color: #a34242; }
      .op-input, .op-select { background: #222; border: 1px solid #555; color: white; border-radius: 4px; padding: 5px; }
      .op-input[type="number"] { width: 80px; }
      .op-input[type="text"] { width: 100%; }
      .op-slider { width: 100%; }
      .op-list { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow: auto; border: 1px solid #444; padding: 6px; border-radius: 6px; background: #1a1a1a; }
      .op-item { display: flex; align-items: center; gap: 6px; padding: 4px; border-radius: 4px; }
      .op-item.active { background: #2a2a2a; }
      .op-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .op-grow { flex: 1; }
      .op-muted { color: #bbb; font-size: 12px; }
      .op-preview { width: 100%; height: 80px; background: #111; display: flex; align-items: center; justify-content: center; border: 1px dashed #444; border-radius: 4px; overflow: hidden; }
      .op-preview img { max-width: 100%; max-height: 100%; display: block; }
    `;
    document.head.appendChild(style);
  }

  function createUI() {
    if (document.getElementById('overlay-pro-panel')) return;
    const panel = document.createElement('div');
    panel.id = 'overlay-pro-panel';
    panel.innerHTML = `
      <div class="op-header" id="op-header">
        <h3>Overlay Pro</h3>
        <button class="op-toggle-btn" id="op-panel-toggle">▶</button>
      </div>
      <div class="op-content" id="op-content">
        <div class="op-section">
          <div class="op-row" style="justify-content: space-between;">
            <div>
              <button class="op-button" id="op-mode-toggle">Mode</button>
            </div>
            <div class="op-row">
              <label class="op-muted">Place overlay:</label>
              <button class="op-button" id="op-autocap-toggle">OFF</button>
            </div>
          </div>
        </div>

        <div class="op-section">
          <div class="op-row" style="justify-content: space-between;">
            <strong>Overlays</strong>
            <div class="op-row">
              <button class="op-button" id="op-add-overlay">+ Add</button>
              <button class="op-button" id="op-import-overlay">Import</button>
            </div>
          </div>
          <div class="op-list" id="op-overlay-list"></div>
          <div class="op-row" style="justify-content: flex-end; gap: 6px;">
            <button class="op-button" id="op-export-overlay">Export</button>
            <button class="op-button danger" id="op-delete-overlay">Delete</button>
          </div>
        </div>

        <div class="op-section" id="op-editor-section">
          <strong>Editor</strong>
          <div class="op-row"><span class="op-muted">Active overlay fields</span></div>

          <div class="op-row">
            <label style="width: 90px;">Name</label>
            <input type="text" class="op-input op-grow" id="op-name">
          </div>

          <div class="op-row">
            <label style="width: 90px;">PNG URL</label>
            <input type="text" class="op-input op-grow" id="op-image-url" placeholder="https://files.catbox.moe/....png">
            <button class="op-button" id="op-load-image">Load</button>
          </div>

          <div class="op-preview"><img id="op-image-preview" alt="No image"></div>

          <div class="op-row">
            <label style="width: 90px;">Pixel URL</label>
            <input type="text" class="op-input op-grow" id="op-pixel-url" placeholder="Place overlay!">
          </div>
          <div class="op-row"><span class="op-muted" id="op-coord-display"></span></div>

          <div class="op-row" style="gap: 5px;">
            <div class="op-row">
              <span>X</span>
              <input type="number" class="op-input" style="width: 55px;" id="op-offset-x">
              <button class="op-button" data-offset="x" data-amount="-1">-</button>
              <button class="op-button" data-offset="x" data-amount="1">+</button>
            </div>
            <div class="op-row">
              <span>Y</span>
              <input type="number" class="op-input" style="width: 55px;" id="op-offset-y">
              <button class="op-button" data-offset="y" data-amount="-1">-</button>
              <button class="op-button" data-offset="y" data-amount="1">+</button>
            </div>
          </div>

          <div class="op-row" style="width: 100%; gap: 12px; padding: 10px;">
            <label style="width: 40px;">Opacity</label>
            <input type="range" min="0" max="1" step="0.05" class="op-slider op-grow" id="op-opacity-slider">
            <span id="op-opacity-value" style="width: 25px; text-align: right;">70%</span>
          </div>
        </div>

        <div class="op-section">
          <button class="op-button" id="op-reload-btn">Reload Tiles (Refresh Page)</button>
        </div>
      </div>
    `;
    document.body.appendChild(panel);
    addEventListeners();
    updateUI();
  }

  function getActiveOverlay() {
    return config.overlays.find(o => o.id === config.activeOverlayId) || null;
  }

  function rebuildOverlayListUI() {
    const list = document.getElementById('op-overlay-list');
    if (!list) return;
    list.innerHTML = '';
    for (const ov of config.overlays) {
      const item = document.createElement('div');
      item.className = 'op-item' + (ov.id === config.activeOverlayId ? ' active' : '');
      item.innerHTML = `
        <input type="radio" name="op-active" ${ov.id === config.activeOverlayId ? 'checked' : ''} />
        <input type="checkbox" ${ov.enabled ? 'checked' : ''} title="Toggle enabled" />
        <div class="op-item-name" title="${ov.name || '(unnamed)'}">${ov.name || '(unnamed)'}</div>
      `;
      const [radio, checkbox, nameDiv] = item.children;
      radio.addEventListener('change', () => {
        config.activeOverlayId = ov.id;
        saveConfig(['activeOverlayId']);
        updateUI();
      });
      checkbox.addEventListener('change', () => {
        ov.enabled = checkbox.checked;
        saveConfig(['overlays']);
        clearOverlayCache();
      });
      nameDiv.addEventListener('click', () => {
        config.activeOverlayId = ov.id;
        saveConfig(['activeOverlayId']);
        updateUI();
      });
      list.appendChild(item);
    }
  }

  async function addOverlayFromUrl(url, name = '') {
    if (!/\.png(\?|$)/i.test(url)) {
      if (!confirm("The URL does not look like a .png. Try anyway?")) return null;
    }
    const base64 = await urlToDataURL(url);
    const ov = {
      id: uid(),
      name: name || 'Overlay',
      enabled: true,
      imageUrl: url,
      imageBase64: base64,
      pixelUrl: null,
      offsetX: 0,
      offsetY: 0,
      opacity: 0.7
    };
    config.overlays.push(ov);
    config.activeOverlayId = ov.id;
    await saveConfig(['overlays', 'activeOverlayId']);
    clearOverlayCache();
    updateUI();
    return ov;
  }

  async function importOverlayFromJSON(jsonText) {
    let obj;
    try {
      obj = JSON.parse(jsonText);
    } catch {
      alert('Invalid JSON');
      return;
    }

    const arr = Array.isArray(obj) ? obj : [obj];
    let imported = 0, failed = 0;
    for (const item of arr) {
      const name = item.name || 'Imported Overlay';
      const imageUrl = item.imageUrl;
      const pixelUrl = item.pixelUrl ?? null;
      const offsetX = Number.isFinite(item.offsetX) ? item.offsetX : 0;
      const offsetY = Number.isFinite(item.offsetY) ? item.offsetY : 0;
      const opacity = Number.isFinite(item.opacity) ? item.opacity : 0.7;
      if (!imageUrl) { failed++; continue; }
      try {
        const base64 = await urlToDataURL(imageUrl);
        const ov = {
          id: uid(),
          name,
          enabled: true,
          imageUrl,
          imageBase64: base64,
          pixelUrl,
          offsetX, offsetY, opacity
        };
        config.overlays.push(ov);
        imported++;
      } catch (e) {
        console.error('Import failed for', imageUrl, e);
        failed++;
      }
    }
    if (imported > 0) {
      config.activeOverlayId = config.overlays[config.overlays.length - 1].id;
      await saveConfig(['overlays', 'activeOverlayId']);
      clearOverlayCache();
      updateUI();
    }
    alert(`Import finished. Imported: ${imported}${failed ? `, Failed: ${failed}` : ''}`);
  }

  function exportActiveOverlayToClipboard() {
    const ov = getActiveOverlay();
    if (!ov) { alert('No active overlay selected.'); return; }
    if (!ov.imageUrl) {
      alert('This overlay has no image URL. Set a direct PNG URL to export a compact JSON.');
      return;
    }
    const payload = {
      version: 1,
      name: ov.name,
      imageUrl: ov.imageUrl,
      pixelUrl: ov.pixelUrl ?? null,
      offsetX: ov.offsetX,
      offsetY: ov.offsetY,
      opacity: ov.opacity
    };
    const text = JSON.stringify(payload, null, 2);
    copyText(text).then(() => alert('Overlay JSON copied to clipboard!')).catch(() => {
      prompt('Copy the JSON below:', text);
    });
  }

  function copyText(text) {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      return navigator.clipboard.writeText(text);
    }
    return Promise.reject(new Error('Clipboard API not available'));
  }

  function addEventListeners() {
    const $ = (id) => document.getElementById(id);

    $('op-header').addEventListener('click', () => {
      config.isPanelCollapsed = !config.isPanelCollapsed;
      saveConfig(['isPanelCollapsed']);
      updateUI();
    });

    $('op-mode-toggle').addEventListener('click', () => {
      config.overlayMode = config.overlayMode === 'overlay' ? 'original' : 'overlay';
      saveConfig(['overlayMode']);
      updateUI();
    });

    $('op-autocap-toggle').addEventListener('click', () => {
      config.autoCapturePixelUrl = !config.autoCapturePixelUrl;
      saveConfig(['autoCapturePixelUrl']);
      updateUI();
    });

    $('op-add-overlay').addEventListener('click', async () => {
      const url = prompt('Enter direct PNG URL (e.g., https://files.catbox.moe/....png):');
      if (!url) return;
      const name = prompt('Enter a name for this overlay:', 'Overlay');
      try {
        await addOverlayFromUrl(url.trim(), (name || '').trim());
      } catch (e) {
        console.error(e);
        alert('Failed to add overlay from URL. See console for details.');
      }
    });

    $('op-import-overlay').addEventListener('click', async () => {
      const text = prompt('Paste overlay JSON (single or array):');
      if (!text) return;
      await importOverlayFromJSON(text);
    });

    $('op-export-overlay').addEventListener('click', () => exportActiveOverlayToClipboard());

    $('op-delete-overlay').addEventListener('click', async () => {
      const ov = getActiveOverlay();
      if (!ov) { alert('No active overlay selected.'); return; }
      if (!confirm(`Delete overlay "${ov.name}"?`)) return;
      const idx = config.overlays.findIndex(o => o.id === ov.id);
      if (idx >= 0) {
        config.overlays.splice(idx, 1);
        if (config.activeOverlayId === ov.id) {
          config.activeOverlayId = config.overlays[0]?.id || null;
        }
        await saveConfig(['overlays', 'activeOverlayId']);
        clearOverlayCache();
        updateUI();
      }
    });

    $('op-load-image').addEventListener('click', async () => {
      const ov = getActiveOverlay();
      if (!ov) { alert('No active overlay selected.'); return; }
      const url = $('op-image-url').value.trim();
      if (!url) { alert('Enter a PNG URL first.'); return; }
      try {
        const base64 = await urlToDataURL(url);
        ov.imageUrl = url;
        ov.imageBase64 = base64;
        await saveConfig(['overlays']);
        clearOverlayCache();
        updateUI();
      } catch (e) {
        console.error(e);
        alert('Failed to load image from URL. Check the link or try a direct PNG.');
      }
    });

    $('op-name').addEventListener('change', async (e) => {
      const ov = getActiveOverlay(); if (!ov) return;
      ov.name = e.target.value;
      await saveConfig(['overlays']);
      rebuildOverlayListUI();
    });

    $('op-image-url').addEventListener('change', (e) => {
    });

    $('op-pixel-url').addEventListener('change', async (e) => {
      const ov = getActiveOverlay(); if (!ov) return;
      const v = e.target.value.trim() || null;
      ov.pixelUrl = v;
      await saveConfig(['overlays']);
      clearOverlayCache();
      updateUI();
    });

    document.querySelectorAll('[data-offset]').forEach(btn => btn.addEventListener('click', async () => {
      const ov = getActiveOverlay(); if (!ov) return;
      const offset = btn.dataset.offset;
      const amount = parseInt(btn.dataset.amount, 10);
      if (offset === 'x') ov.offsetX += amount;
      if (offset === 'y') ov.offsetY += amount;
      await saveConfig(['overlays']);
      clearOverlayCache();
      updateUI();
    }));

    $('op-offset-x').addEventListener('change', async (e) => {
      const ov = getActiveOverlay(); if (!ov) return;
      const v = parseInt(e.target.value, 10);
      ov.offsetX = Number.isFinite(v) ? v : 0;
      await saveConfig(['overlays']);
      clearOverlayCache();
      updateUI();
    });

    $('op-offset-y').addEventListener('change', async (e) => {
      const ov = getActiveOverlay(); if (!ov) return;
      const v = parseInt(e.target.value, 10);
      ov.offsetY = Number.isFinite(v) ? v : 0;
      await saveConfig(['overlays']);
      clearOverlayCache();
      updateUI();
    });

    $('op-opacity-slider').addEventListener('input', (e) => {
      const ov = getActiveOverlay(); if (!ov) return;
      ov.opacity = parseFloat(e.target.value);
      document.getElementById('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
    });
    $('op-opacity-slider').addEventListener('change', async () => {
      await saveConfig(['overlays']);
      clearOverlayCache();
    });

    $('op-reload-btn').addEventListener('click', () => location.reload());
  }

  function updateEditorUI() {
    const $ = (id) => document.getElementById(id);
    const ov = getActiveOverlay();

    const editor = $('op-editor-section');
    editor.style.display = ov ? 'block' : 'none';
    if (!ov) return;

    $('op-name').value = ov.name || '';
    $('op-image-url').value = ov.imageUrl || '';
    $('op-pixel-url').value = ov.pixelUrl || '';

    const preview = $('op-image-preview');
    if (ov.imageBase64) {
      preview.src = ov.imageBase64;
    } else {
      preview.removeAttribute('src');
    }

    const coords = ov.pixelUrl ? extractPixelCoords(ov.pixelUrl) : { chunk1: '-', chunk2: '-', posX: '-', posY: '-' };
    $('op-coord-display').textContent = ov.pixelUrl
      ? `Ref: chunk ${coords.chunk1}/${coords.chunk2} at (${coords.posX}, ${coords.posY})`
      : `No pixel URL set`;

    $('op-offset-x').value = ov.offsetX;
    $('op-offset-y').value = ov.offsetY;
    $('op-opacity-slider').value = String(ov.opacity);
    $('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
  }

  function updateUI() {
    const $ = (id) => document.getElementById(id);
    const panel = $('overlay-pro-panel');
    if (!panel) return;

    const content = $('op-content');
    const toggle = $('op-panel-toggle');

    const collapsed = !!config.isPanelCollapsed;
    content.style.display = collapsed ? 'none' : 'flex';
    toggle.textContent = collapsed ? '▶' : '◀';

    const modeBtn = $('op-mode-toggle');
    modeBtn.textContent = `Mode: ${config.overlayMode === 'overlay' ? 'Overlay' : 'Original'}`;
    modeBtn.classList.toggle('active', config.overlayMode === 'overlay');

    const autoBtn = $('op-autocap-toggle');
    autoBtn.textContent = config.autoCapturePixelUrl ? 'ON' : 'OFF';
    autoBtn.classList.toggle('active', !!config.autoCapturePixelUrl);

    rebuildOverlayListUI();
    updateEditorUI();
  }

  async function main() {
    await loadConfig();
    injectStyles();

    if (document.readyState === 'loading') {
      window.addEventListener('DOMContentLoaded', createUI);
    } else {
      createUI();
    }

    hookFetch();

    console.log("Overlay Pro — Collections v2.0.0: Initialized.");
  }

  main();
})();