Wplace Overlay Pro

Overlays tiles on wplace.live. Can also color-match your overlay to wplace's palette.

当前为 2025-08-10 提交的版本,查看 最新版本

// ==UserScript==
// @name         Wplace Overlay Pro
// @namespace    http://tampermonkey.net/
// @version      2.6.0
// @description  Overlays tiles on wplace.live. Can also color-match your overlay to wplace's palette.
// @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';

  const TILE_SIZE = 1000;
  const MAX_OVERLAY_DIM = 1000;

  const NATIVE_FETCH = window.fetch;

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

  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: () => 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);
  }
  function fileToDataURL(file) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload = () => resolve(fr.result);
      fr.onerror = reject;
      fr.readAsDataURL(file);
    });
  }

  const WPLACE_FREE = [
    [0,0,0], [60,60,60], [120,120,120], [210,210,210], [255,255,255],
    [96,0,24], [237,28,36], [255,127,39], [246,170,9], [249,221,59], [255,250,188],
    [14,185,104], [19,230,123], [135,255,94],
    [12,129,110], [16,174,166], [19,225,190], [96,247,242],
    [40,80,158], [64,147,228],
    [107,80,246], [153,177,251],
    [120,12,153], [170,56,185], [224,159,249],
    [203,0,122], [236,31,128], [243,141,169],
    [104,70,52], [149,104,42], [248,178,119]
  ];
  const WPLACE_PAID = [
    [170,170,170],
    [165,14,30], [250,128,114],
    [228,92,26], [156,132,49], [197,173,49], [232,212,95],
    [74,107,58], [90,148,74], [132,197,115],
    [15,121,159], [187,250,242], [125,199,255],
    [77,49,184], [74,66,132], [122,113,196], [181,174,241],
    [155,82,73], [209,128,120], [250,182,164],
    [219,164,99], [123,99,82], [156,132,107], [214,181,148],
    [209,128,81], [255,197,165],
    [109,100,63], [148,140,107], [205,197,158],
    [51,57,65], [109,117,141], [179,185,209]
  ];
  const WPLACE_NAMES = {
    "0,0,0":"Black","60,60,60":"Dark Gray","120,120,120":"Gray","210,210,210":"Light Gray","255,255,255":"White",
    "96,0,24":"Deep Red","237,28,36":"Red","255,127,39":"Orange","246,170,9":"Gold","249,221,59":"Yellow","255,250,188":"Light Yellow",
    "14,185,104":"Dark Green","19,230,123":"Green","135,255,94":"Light Green",
    "12,129,110":"Dark Teal","16,174,166":"Teal","19,225,190":"Light Teal","96,247,242":"Cyan",
    "40,80,158":"Dark Blue","64,147,228":"Blue",
    "107,80,246":"Indigo","153,177,251":"Light Indigo",
    "120,12,153":"Dark Purple","170,56,185":"Purple","224,159,249":"Light Purple",
    "203,0,122":"Dark Pink","236,31,128":"Pink","243,141,169":"Light Pink",
    "104,70,52":"Dark Brown","149,104,42":"Brown","248,178,119":"Beige",
    "170,170,170":"Medium Gray","165,14,30":"Dark Red","250,128,114":"Light Red",
    "228,92,26":"Dark Orange","156,132,49":"Dark Goldenrod","197,173,49":"Goldenrod","232,212,95":"Light Goldenrod",
    "74,107,58":"Dark Olive","90,148,74":"Olive","132,197,115":"Light Olive",
    "15,121,159":"Dark Cyan","187,250,242":"Light Cyan","125,199,255":"Light Blue",
    "77,49,184":"Dark Indigo","74,66,132":"Dark Slate Blue","122,113,196":"Slate Blue","181,174,241":"Light Slate Blue",
    "155,82,73":"Dark Peach","209,128,120":"Peach","250,182,164":"Light Peach",
    "219,164,99":"Light Brown","123,99,82":"Dark Tan","156,132,107":"Tan","214,181,148":"Light Tan",
    "209,128,81":"Dark Beige","255,197,165":"Light Beige",
    "109,100,63":"Dark Stone","148,140,107":"Stone","205,197,158":"Light Stone",
    "51,57,65":"Dark Slate","109,117,141":"Slate","179,185,209":"Light Slate"
  };
  const DEFAULT_FREE_KEYS = WPLACE_FREE.map(([r,g,b]) => `${r},${g},${b}`);
  const DEFAULT_PAID_KEYS = [];

  const page = unsafeWindow;

  function uid() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; }
  function uniqueName(base) {
    const names = new Set(config.overlays.map(o => (o.name || '').toLowerCase()));
    if (!names.has(base.toLowerCase())) return base;
    let i = 1; while (names.has(`${base} (${i})`.toLowerCase())) i++; return `${base} (${i})`;
  }

  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(); 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 {} }
    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), y = Math.max(ay, by);
    const r = Math.min(ax + aw, bx + bw), b = Math.min(ay + ah, by + bh);
    const w = Math.max(0, r - x), h = Math.max(0, b - y);
    return { x, y, w, h };
  }

  const overlayCache = new Map();
  const tooLargeOverlays = new Set();

  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;
    if (tooLargeOverlays.has(ov.id)) 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 wImg = img.width, hImg = img.height;
    if (wImg >= MAX_OVERLAY_DIM || hImg >= MAX_OVERLAY_DIM) {
      tooLargeOverlays.add(ov.id);
      showToast(`Overlay "${ov.name}" skipped: image too large (must be smaller than ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}; got ${wImg}×${hImg}).`);
      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, wImg, hImg);
    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', { willReadFrequently: true });
    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, 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 showToast(message, duration = 3000) {
    let stack = document.getElementById('op-toast-stack');
    if (!stack) {
      stack = document.createElement('div');
      stack.className = 'op-toast-stack';
      stack.id = 'op-toast-stack';
      document.body.appendChild(stack);
    }
    stack.classList.toggle('op-dark', config.theme === 'dark');

    const t = document.createElement('div');
    t.className = 'op-toast';
    t.textContent = message;
    stack.appendChild(t);
    requestAnimationFrame(() => t.classList.add('show'));
    setTimeout(() => {
      t.classList.remove('show');
      setTimeout(() => t.remove(), 200);
    }, duration);
  }

  let hookInstalled = false;
  function overlaysNeedingHook() {
    const hasImage = config.overlays.some(o => o.enabled && o.imageBase64);
    const placing  = !!config.autoCapturePixelUrl && !!config.activeOverlayId;
    return (config.overlayMode === 'overlay') && (hasImage || placing) && config.overlays.length > 0;
  }
  function ensureHook() { if (overlaysNeedingHook()) attachHook(); else detachHook(); }

  function attachHook() {
    if (hookInstalled) return;
    const originalFetch = NATIVE_FETCH;
    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) {
            const changed = (ov.pixelUrl !== pixelMatch.normalized);
            if (changed) {
              ov.pixelUrl = pixelMatch.normalized;
              ov.offsetX = 0; ov.offsetY = 0;
              await saveConfig(['overlays']); clearOverlayCache();

              config.autoCapturePixelUrl = false; await saveConfig(['autoCapturePixelUrl']); updateUI();

              const c = extractPixelCoords(ov.pixelUrl);
              showToast(`Anchor set for "${ov.name}": chunk ${c.chunk1}/${c.chunk2} at (${c.posX}, ${c.posY}). Offset reset to (0,0).`);
              ensureHook();
            }
          }
        }
      }

      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 ct = (response.headers.get('Content-Type') || '').toLowerCase();
        if (!ct.includes('image')) 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();
        if (originalBlob.size > 15 * 1024 * 1024) return response;

        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);
      }
    };
    page.fetch = hookedFetch;
    window.fetch = hookedFetch;
    hookInstalled = true;
  }
  function detachHook() { if (!hookInstalled) return; page.fetch = NATIVE_FETCH; window.fetch = NATIVE_FETCH; hookInstalled = false; }

  const config = {
    overlays: [],
    activeOverlayId: null,
    overlayMode: 'overlay',
    isPanelCollapsed: false,
    autoCapturePixelUrl: false,
    panelX: null,
    panelY: null,
    theme: 'light',
    collapseList: false,
    collapseEditor: false,
    collapseNudge: false,

    ccFreeKeys: DEFAULT_FREE_KEYS.slice(),
    ccPaidKeys: DEFAULT_PAID_KEYS.slice(),
    ccZoom: 1.0,
    ccRealtime: 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]); }));
      if (!Array.isArray(config.ccFreeKeys) || config.ccFreeKeys.length === 0) config.ccFreeKeys = DEFAULT_FREE_KEYS.slice();
      if (!Array.isArray(config.ccPaidKeys)) config.ccPaidKeys = DEFAULT_PAID_KEYS.slice();
      if (!Number.isFinite(config.ccZoom) || config.ccZoom <= 0) config.ccZoom = 1.0;
      if (typeof config.ccRealtime !== 'boolean') config.ccRealtime = false;
    } 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); }
  }

  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = `
      body.op-theme-light {
        --op-bg: #ffffff;
        --op-border: #e6ebf2;
        --op-muted: #6b7280;
        --op-text: #111827;
        --op-subtle: #f4f6fb;
        --op-btn: #eef2f7;
        --op-btn-border: #d8dee8;
        --op-btn-hover: #e7ecf5;
        --op-accent: #1e88e5;
      }
      body.op-theme-dark {
        --op-bg: #1b1e24;
        --op-border: #2a2f3a;
        --op-muted: #a0a7b4;
        --op-text: #f5f6f9;
        --op-subtle: #151922;
        --op-btn: #262b36;
        --op-btn-border: #384050;
        --op-btn-hover: #2f3542;
        --op-accent: #64b5f6;
      }
      .op-scroll-lock { overflow: hidden !important; }

      #overlay-pro-panel {
        position: fixed; z-index: 9999; background: var(--op-bg); border: 1px solid var(--op-border);
        border-radius: 16px; color: var(--op-text); font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
        font-size: 14px; width: 340px; box-shadow: 0 10px 24px rgba(16,24,40,0.12), 0 2px 6px rgba(16,24,40,0.08); user-select: none;
      }

      .op-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--op-border); border-radius: 16px 16px 0 0; cursor: grab; }
      .op-header:active { cursor: grabbing; }
      .op-header h3 { margin: 0; font-size: 15px; font-weight: 600; }
      .op-header-actions { display: flex; gap: 6px; }
      .op-toggle-btn, .op-hdr-btn { background: transparent; border: 1px solid var(--op-border); color: var(--op-text); border-radius: 10px; padding: 4px 8px; cursor: pointer; }
      .op-toggle-btn:hover, .op-hdr-btn:hover { background: var(--op-btn); }

      .op-content { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
      .op-section { display: flex; flex-direction: column; gap: 8px; background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; padding: 5px; }

      .op-section-title { display: flex; align-items: center; justify-content: space-between; }
      .op-title-text { font-weight: 600; }
      .op-chevron { background: transparent; border: 1px solid var(--op-border); border-radius: 8px; padding: 2px 6px; cursor: pointer; }
      .op-chevron:hover { background: var(--op-btn); }

      .op-row { display: flex; align-items: center; gap: 8px; }
      .op-row.space { justify-content: space-between; }

      .op-button { background: var(--op-btn); color: var(--op-text); border: 1px solid var(--op-btn-border); border-radius: 10px; padding: 6px 10px; cursor: pointer; }
      .op-button:hover { background: var(--op-btn-hover); }
      .op-button:disabled { opacity: 0.5; cursor: not-allowed; }
      .op-button.icon { width: 30px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 16px; }

      .op-input, .op-select { background: #fff; border: 1px solid var(--op-border); color: var(--op-text); border-radius: 10px; padding: 6px 8px; }
      .op-slider { width: 100%; }

      .op-list { display: flex; flex-direction: column; gap: 6px; max-height: 140px; overflow: auto; border: 1px solid var(--op-border); padding: 6px; border-radius: 10px; background: #fff; }

      .op-item { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 8px; border: 1px solid var(--op-border); background: var(--op-subtle); }
      .op-item.active { outline: 2px solid color-mix(in oklab, var(--op-accent) 35%, transparent); background: #fff; }
      .op-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

      .op-muted { color: var(--op-muted); font-size: 12px; }

      .op-preview { width: 100%; height: 90px; background: #fff; display: flex; align-items: center; justify-content: center; border: 2px dashed color-mix(in oklab, var(--op-accent) 40%, var(--op-border)); border-radius: 10px; overflow: hidden; position: relative; cursor: pointer; }
      .op-preview img { max-width: 100%; max-height: 100%; display: block; pointer-events: none; }
      .op-preview.drop-highlight { background: color-mix(in oklab, var(--op-accent) 12%, transparent); }
      .op-preview .op-drop-hint { position: absolute; bottom: 6px; right: 8px; font-size: 11px; color: var(--op-muted); pointer-events: none; }

      .op-icon-btn { background: var(--op-btn); color: var(--op-text); border: 1px solid var(--op-btn-border); border-radius: 10px; width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
      .op-icon-btn:hover { background: var(--op-btn-hover); }

      .op-danger { background: #fee2e2; border-color: #fecaca; color: #7f1d1d; }
      .op-danger-text { color: #dc2626; font-weight: 600; }

      .op-toast-stack { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; z-index: 999999; width: min(92vw, 480px); }
      .op-toast { background: rgba(255,255,255,0.98); border: 1px solid #e6ebf2; color: #111827; padding: 8px 12px; border-radius: 10px; font-size: 12px; box-shadow: 0 6px 16px rgba(16,24,40,0.12); opacity: 0; transform: translateY(-6px); transition: opacity .18s ease, transform .18s ease; max-width: 100%; text-align: center; }
      .op-toast.show { opacity: 1; transform: translateY(0); }

      /* Color Match Modal */
      .op-cc-backdrop { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.45); display: none; }
      .op-cc-backdrop.show { display: block; }

      .op-cc-modal {
        position: fixed; z-index: 10001;
        width: min(1100px, 96vw);
        max-height: 92vh;
        left: 50%; top: 50%; transform: translate(-50%, -50%);
        background: var(--op-bg); color: var(--op-text);
        border: 1px solid var(--op-border);
        border-radius: 14px;
        box-shadow: 0 16px 48px rgba(0,0,0,0.28);
        display: none; /* hidden by default */
        flex-direction: column;
      }
      .op-cc-header { padding: 10px 12px; border-bottom: 1px solid var(--op-border); display: flex; align-items: center; justify-content: space-between; cursor: move; user-select: none; }
      .op-cc-title { font-weight: 600; }
      .op-cc-close { border: 1px solid var(--op-border); background: transparent; border-radius: 8px; padding: 4px 8px; cursor: pointer; }
      .op-cc-close:hover { background: var(--op-btn); }
      .op-cc-pill { border-radius: 999px; padding: 4px 10px; border: 1px solid var(--op-border); background: var(--op-bg); }

      .op-cc-body {
        display: grid;
        grid-template-columns: 1.6fr 380px;
        grid-template-areas: "preview controls";
        gap: 12px;
        padding: 12px;
        overflow: hidden;
      }
      @media (max-width: 860px) {
        .op-cc-body { grid-template-columns: 1fr; grid-template-areas: "preview" "controls"; max-height: calc(92vh - 100px); overflow: auto; }
      }

      .op-cc-preview-wrap { grid-area: preview; background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; position: relative; min-height: 320px; display: flex; align-items: center; justify-content: center; overflow: auto; }
      .op-cc-canvas { image-rendering: pixelated; }
      .op-cc-zoom { position: absolute; top: 8px; right: 8px; display: inline-flex; gap: 6px; }
      .op-cc-zoom .op-icon-btn { width: 34px; height: 34px; }

      .op-cc-controls { grid-area: controls; display: flex; flex-direction: column; gap: 12px; background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; padding: 10px; overflow: auto; max-height: calc(92vh - 160px); }
      .op-cc-block { display: flex; flex-direction: column; gap: 6px; }
      .op-cc-block label { color: var(--op-muted); font-weight: 600; }

      .op-cc-palette { display: flex; flex-direction: column; gap: 8px; background: var(--op-bg); border: 1px dashed var(--op-border); border-radius: 10px; padding: 8px; }
      .op-cc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(22px, 22px)); gap: 6px; }
      .op-cc-cell { width: 22px; height: 22px; border-radius: 4px; border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.15) inset; cursor: pointer; }
      .op-cc-cell.active { outline: 2px solid var(--op-accent); }

      .op-cc-footer { padding: 10px 12px; border-top: 1px solid var(--op-border); display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
      .op-cc-actions { display: inline-flex; gap: 8px; }
      .op-cc-ghost { color: var(--op-muted); font-size: 12px; }
    `;
    document.head.appendChild(style);
  }

  function createUI() {
    if (document.getElementById('overlay-pro-panel')) return;
    const panel = document.createElement('div');
    panel.id = 'overlay-pro-panel';

    const panelW = 340;
    const defaultLeft = Math.max(12, window.innerWidth - panelW - 80);
    panel.style.left = (Number.isFinite(config.panelX) ? config.panelX : defaultLeft) + 'px';
    panel.style.top = (Number.isFinite(config.panelY) ? config.panelY : 120) + 'px';

    panel.innerHTML = `
      <div class="op-header" id="op-header">
        <h3>Overlay Pro</h3>
        <div class="op-header-actions">
          <button class="op-hdr-btn" id="op-theme-toggle" title="Toggle theme">☀️/🌙</button>
          <button class="op-hdr-btn" id="op-refresh-btn" title="Refresh">⟲</button>
          <button class="op-toggle-btn" id="op-panel-toggle" title="Collapse">▾</button>
        </div>
      </div>
      <div class="op-content" id="op-content">
        <div class="op-section">
          <div class="op-row space">
            <button class="op-button" id="op-mode-toggle">Mode</button>
            <div class="op-row">
              <span class="op-muted" id="op-place-label">Place overlay:</span>
              <button class="op-button" id="op-autocap-toggle" title="Capture next clicked pixel as anchor">OFF</button>
            </div>
          </div>
        </div>

        <div class="op-section">
          <div class="op-section-title">
            <div class="op-title-left">
              <span class="op-title-text">Overlays</span>
            </div>
            <div class="op-title-right">
              <div class="op-row">
                <button class="op-button" id="op-add-overlay" title="Create a new overlay">+ Add</button>
                <button class="op-button" id="op-import-overlay" title="Import overlay JSON">Import</button>
                <button class="op-button" id="op-export-overlay" title="Export active overlay JSON">Export</button>
                <button class="op-chevron" id="op-collapse-list" title="Collapse/Expand">▾</button>
                </div>
            </div>
          </div>
          <div id="op-list-wrap">
            <div class="op-list" id="op-overlay-list"></div>
          </div>
        </div>

        <div class="op-section" id="op-editor-section">
          <div class="op-section-title">
            <div class="op-title-left">
              <span class="op-title-text">Editor</span>
            </div>
            <div class="op-title-right">
              <button class="op-chevron" id="op-collapse-editor" title="Collapse/Expand">▾</button>
            </div>
          </div>

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

            <div id="op-image-source">
              <div class="op-row">
                <label style="width: 90px;">Image</label>
                <input type="text" class="op-input op-grow" id="op-image-url" placeholder="Paste a direct image link">
                <button class="op-button" id="op-fetch">Fetch</button>
              </div>
              <div class="op-preview" id="op-dropzone">
                <div class="op-drop-hint">Drop here or click to browse.</div>
                <input type="file" id="op-file-input" accept="image/*" style="display:none">
              </div>
            </div>

            <div class="op-preview" id="op-preview-wrap" style="display:none;">
              <img id="op-image-preview" alt="No image">
            </div>

            <div class="op-row" id="op-cc-btn-row" style="display:none; justify-content:space-between;">
              <button class="op-button" id="op-download-overlay" title="Download this overlay image">Download</button>
              <button class="op-button" id="op-open-cc" title="Match colors to Wplace palette">Color Match</button>
            </div>

            <div class="op-row"><span class="op-muted" id="op-coord-display"></span></div>

            <div class="op-row" style="width: 100%; gap: 12px; padding: 6px 0;">
              <label style="width: 60px;">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: 36px; text-align: right;">70%</span>
            </div>
          </div>
        </div>

        <div class="op-section" id="op-nudge-section">
          <div class="op-section-title">
            <div class="op-title-left">
              <span class="op-title-text">Nudge overlay</span>
            </div>
            <div class="op-title-right">
              <span class="op-muted" id="op-offset-indicator">Offset X 0, Y 0</span>
              <button class="op-chevron" id="op-collapse-nudge" title="Collapse/Expand">▾</button>
            </div>
          </div>
          <div id="op-nudge-body">
            <div class="op-nudge-row" style="text-align: right;">
              <button class="op-icon-btn" id="op-nudge-left" title="Left">←</button>
              <button class="op-icon-btn" id="op-nudge-down" title="Down">↓</button>
              <button class="op-icon-btn" id="op-nudge-up" title="Up">↑</button>
              <button class="op-icon-btn" id="op-nudge-right" title="Right">→</button>
            </div>
          </div>
        </div>
      </div>
    `;
    document.body.appendChild(panel);

    buildCCModal();
    addEventListeners();
    enableDrag(panel);
    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' : '');
      const localTag = ov.isLocal ? ' (local)' : (!ov.imageBase64 ? ' (no image)' : '');
      item.innerHTML = `
        <input type="radio" name="op-active" ${ov.id === config.activeOverlayId ? 'checked' : ''} title="Set active"/>
        <input type="checkbox" ${ov.enabled ? 'checked' : ''} title="Toggle enabled"/>
        <div class="op-item-name" title="${(ov.name || '(unnamed)') + localTag}">${(ov.name || '(unnamed)') + localTag}</div>
        <button class="op-icon-btn" title="Delete overlay">🗑️</button>
      `;
      const [radio, checkbox, nameDiv, trashBtn] = item.children;

      radio.addEventListener('change', () => { config.activeOverlayId = ov.id; saveConfig(['activeOverlayId']); updateUI(); });
      checkbox.addEventListener('change', () => { ov.enabled = checkbox.checked; saveConfig(['overlays']); clearOverlayCache(); ensureHook(); });
      nameDiv.addEventListener('click', () => { config.activeOverlayId = ov.id; saveConfig(['activeOverlayId']); updateUI(); });
      trashBtn.addEventListener('click', async (e) => {
        e.stopPropagation();
        if (!confirm(`Delete overlay "${ov.name || '(unnamed)'}"?`)) 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(); ensureHook(); updateUI();
        }
      });

      list.appendChild(item);
    }
  }

  async function addBlankOverlay() {
    const name = uniqueName('Overlay');
    const ov = { id: uid(), name, enabled: true, imageUrl: null, imageBase64: null, isLocal: false, pixelUrl: null, offsetX: 0, offsetY: 0, opacity: 0.7 };
    config.overlays.push(ov);
    config.activeOverlayId = ov.id;
    await saveConfig(['overlays', 'activeOverlayId']);
    clearOverlayCache(); ensureHook(); updateUI();
    return ov;
  }

  async function setOverlayImageFromURL(ov, url) {
    const base64 = await urlToDataURL(url);
    ov.imageUrl = url; ov.imageBase64 = base64; ov.isLocal = false;
    await saveConfig(['overlays']); clearOverlayCache();
    config.autoCapturePixelUrl = true; await saveConfig(['autoCapturePixelUrl']);
    ensureHook(); updateUI();
    showToast(`Image loaded. Placement mode ON -- click once to set anchor.`);
  }
  async function setOverlayImageFromFile(ov, file) {
    if (!file || !file.type || !file.type.startsWith('image/')) { alert('Please choose an image file.'); return; }
    if (!confirm('Local PNGs cannot be exported to friends! Are you sure?')) return;
    const base64 = await fileToDataURL(file);
    ov.imageBase64 = base64; ov.imageUrl = null; ov.isLocal = true;
    await saveConfig(['overlays']); clearOverlayCache();
    config.autoCapturePixelUrl = true; await saveConfig(['autoCapturePixelUrl']);
    ensureHook(); updateUI();
    showToast(`Local image loaded. Placement mode ON -- click once to set anchor.`);
  }

  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 = uniqueName(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, isLocal: false, 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(); ensureHook(); updateUI();
    }
    alert(`Import finished. Imported: ${imported}${failed ? `, Failed: ${failed}` : ''}`);
  }

  function exportActiveOverlayToClipboard() {
    const ov = getActiveOverlay();
    if (!ov) { alert('No active overlay selected.'); return; }
    if (ov.isLocal || !ov.imageUrl) { alert('This overlay uses a local image and cannot be exported. Please host the image and set an image URL.'); 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-theme-toggle').addEventListener('click', async (e) => { e.stopPropagation(); config.theme = config.theme === 'light' ? 'dark' : 'light'; await saveConfig(['theme']); applyTheme(); });
    $('op-refresh-btn').addEventListener('click', (e) => { e.stopPropagation(); location.reload(); });
    $('op-panel-toggle').addEventListener('click', (e) => { e.stopPropagation(); config.isPanelCollapsed = !config.isPanelCollapsed; saveConfig(['isPanelCollapsed']); updateUI(); });

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

    $('op-add-overlay').addEventListener('click', async () => { try { await addBlankOverlay(); } catch (e) { console.error(e); } });
    $('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-collapse-list').addEventListener('click', () => { config.collapseList = !config.collapseList; saveConfig(['collapseList']); updateUI(); });
    $('op-collapse-editor').addEventListener('click', () => { config.collapseEditor = !config.collapseEditor; saveConfig(['collapseEditor']); updateUI(); });
    $('op-collapse-nudge').addEventListener('click', () => { config.collapseNudge = !config.collapseNudge; saveConfig(['collapseNudge']); updateUI(); });

    $('op-name').addEventListener('change', async (e) => {
      const ov = getActiveOverlay(); if (!ov) return;
      const desired = (e.target.value || '').trim() || 'Overlay';
      if (config.overlays.some(o => o.id !== ov.id && (o.name || '').toLowerCase() === desired.toLowerCase())) { ov.name = uniqueName(desired); showToast(`Name in use. Renamed to "${ov.name}".`); } else { ov.name = desired; }
      await saveConfig(['overlays']); rebuildOverlayListUI();
    });

    $('op-fetch').addEventListener('click', async () => {
      const ov = getActiveOverlay(); if (!ov) { alert('No active overlay selected.'); return; }
      if (ov.imageBase64) { alert('This overlay already has an image. Create a new overlay to change the image.'); return; }
      const url = $('op-image-url').value.trim(); if (!url) { alert('Enter an image link first.'); return; }
      try { await setOverlayImageFromURL(ov, url); } catch (e) { console.error(e); alert('Failed to fetch image.'); }
    });

    const dropzone = $('op-dropzone');
    dropzone.addEventListener('click', () => $('op-file-input').click());
    $('op-file-input').addEventListener('change', async (e) => {
      const file = e.target.files && e.target.files[0]; e.target.value=''; if (!file) return;
      const ov = getActiveOverlay(); if (!ov) return;
      if (ov.imageBase64) { alert('This overlay already has an image. Create a new overlay to change the image.'); return; }
      try { await setOverlayImageFromFile(ov, file); } catch (err) { console.error(err); alert('Failed to load local image.'); }
    });
    ['dragenter', 'dragover'].forEach(evt => dropzone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropzone.classList.add('drop-highlight'); }));
    ['dragleave', 'drop'].forEach(evt => dropzone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); if (evt === 'dragleave' && e.target !== dropzone) return; dropzone.classList.remove('drop-highlight'); }));
    dropzone.addEventListener('drop', async (e) => {
      const dt = e.dataTransfer; if (!dt) return; const file = dt.files && dt.files[0]; if (!file) return;
      const ov = getActiveOverlay(); if (!ov) return;
      if (ov.imageBase64) { alert('This overlay already has an image. Create a new overlay to change the image.'); return; }
      try { await setOverlayImageFromFile(ov, file); } catch (err) { console.error(err); alert('Failed to load dropped image.'); }
    });

    const nudge = async (dx, dy) => { const ov = getActiveOverlay(); if (!ov) return; ov.offsetX += dx; ov.offsetY += dy; await saveConfig(['overlays']); clearOverlayCache(); updateUI(); };
    $('op-nudge-up').addEventListener('click', () => nudge(0, -1));
    $('op-nudge-down').addEventListener('click', () => nudge(0, 1));
    $('op-nudge-left').addEventListener('click', () => nudge(-1, 0));
    $('op-nudge-right').addEventListener('click', () => nudge(1, 0));

    $('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-download-overlay').addEventListener('click', () => {
      const ov = getActiveOverlay();
      if (!ov || !ov.imageBase64) { showToast('No overlay image to download.'); return; }
      const a = document.createElement('a');
      a.href = ov.imageBase64;
      a.download = `${(ov.name || 'overlay').replace(/[^\w.-]+/g, '_')}.png`;
      document.body.appendChild(a);
      a.click();
      a.remove();
    });

    $('op-open-cc').addEventListener('click', () => {
      const ov = getActiveOverlay(); if (!ov || !ov.imageBase64) { showToast('No overlay image to edit.'); return; }
      openCCModal(ov);
    });
  }

  function enableDrag(panel) {
    const header = panel.querySelector('#op-header');
    if (!header) return;

    let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0, moved = false;
    const clamp = (v, min, max) => Math.min(Math.max(v, min), max);

    const onPointerDown = (e) => {
      if (e.target.closest('button')) return;
      isDragging = true; moved = false; startX = e.clientX; startY = e.clientY;
      const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top;
      header.setPointerCapture?.(e.pointerId); e.preventDefault();
    };
    const onPointerMove = (e) => {
      if (!isDragging) return;
      const dx = e.clientX - startX, dy = e.clientY - startY;
      const maxLeft = Math.max(8, window.innerWidth - panel.offsetWidth - 8);
      const maxTop  = Math.max(8, window.innerHeight - panel.offsetHeight - 8);
      panel.style.left = clamp(startLeft + dx, 8, maxLeft) + 'px';
      panel.style.top  = clamp(startTop  + dy, 8, maxTop)  + 'px';
      moved = true;
    };
    const onPointerUp = (e) => {
      if (!isDragging) return;
      isDragging = false; header.releasePointerCapture?.(e.pointerId);
      if (moved) { config.panelX = parseInt(panel.style.left, 10) || 0; config.panelY = parseInt(panel.style.top, 10) || 0; saveConfig(['panelX', 'panelY']); }
    };
    header.addEventListener('pointerdown', onPointerDown);
    header.addEventListener('pointermove', onPointerMove);
    header.addEventListener('pointerup', onPointerUp);
    header.addEventListener('pointercancel', onPointerUp);

    window.addEventListener('resize', () => {
      const rect = panel.getBoundingClientRect();
      const maxLeft = Math.max(8, window.innerWidth - panel.offsetWidth - 8);
      const maxTop  = Math.max(8, window.innerHeight - panel.offsetHeight - 8);
      const newLeft = Math.min(Math.max(rect.left, 8), maxLeft);
      const newTop  = Math.min(Math.max(rect.top, 8), maxTop);
      panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px';
      config.panelX = newLeft; config.panelY = newTop; saveConfig(['panelX', 'panelY']);
    });
  }

  function applyTheme() {
    document.body.classList.toggle('op-theme-dark', config.theme === 'dark');
    document.body.classList.toggle('op-theme-light', config.theme !== 'dark');
    const stack = document.getElementById('op-toast-stack');
    if (stack) stack.classList.toggle('op-dark', config.theme === 'dark');
  }

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

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

    $('op-name').value = ov.name || '';

    const srcWrap = $('op-image-source');
    const previewWrap = $('op-preview-wrap');
    const previewImg = $('op-image-preview');
    const ccRow = $('op-cc-btn-row');

    if (ov.imageBase64) {
      srcWrap.style.display = 'none';
      previewWrap.style.display = 'flex';
      previewImg.src = ov.imageBase64;
      ccRow.style.display = 'flex';
    } else {
      srcWrap.style.display = 'block';
      previewWrap.style.display = 'none';
      ccRow.style.display = 'none';
      $('op-image-url').value = ov.imageUrl || '';
    }

    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 anchor set. Turn ON "Place overlay" and click a pixel once.`;

    $('op-opacity-slider').value = String(ov.opacity);
    $('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';

    const indicator = document.getElementById('op-offset-indicator');
    if (indicator) indicator.textContent = `Offset X ${ov.offsetX}, Y ${ov.offsetY}`;

    editorBody.style.display = config.collapseEditor ? 'none' : 'block';
    const chevron = document.getElementById('op-collapse-editor');
    if (chevron) chevron.textContent = config.collapseEditor ? '▸' : '▾';
  }

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

    applyTheme();

    const content = $('op-content');
    const toggle = $('op-panel-toggle');
    const collapsed = !!config.isPanelCollapsed;
    content.style.display = collapsed ? 'none' : 'flex';
    toggle.textContent = collapsed ? '▸' : '▾';
    toggle.title = collapsed ? 'Expand' : 'Collapse';

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

    const autoBtn = $('op-autocap-toggle');
    const placeLabel = $('op-place-label');
    autoBtn.textContent = config.autoCapturePixelUrl ? 'ON' : 'OFF';
    autoBtn.classList.toggle('op-danger', !!config.autoCapturePixelUrl);
    placeLabel.classList.toggle('op-danger-text', !!config.autoCapturePixelUrl);

    const listWrap = $('op-list-wrap');
    const listCz = $('op-collapse-list');
    listWrap.style.display = config.collapseList ? 'none' : 'block';
    if (listCz) listCz.textContent = config.collapseList ? '▸' : '▾';

    const nudgeBody = $('op-nudge-body');
    const nudgeCz = $('op-collapse-nudge');
    nudgeBody.style.display = config.collapseNudge ? 'none' : 'block';
    if (nudgeCz) nudgeCz.textContent = config.collapseNudge ? '▸' : '▾';

    rebuildOverlayListUI();
    updateEditorUI();

    const exportBtn = $('op-export-overlay');
    const ov = getActiveOverlay();
    const canExport = !!(ov && ov.imageUrl && !ov.isLocal);
    exportBtn.disabled = !canExport;
    exportBtn.title = canExport ? 'Export active overlay JSON' : 'Export disabled for local images';
  }

  let cc = null;

  function buildCCModal() {
    const backdrop = document.createElement('div');
    backdrop.className = 'op-cc-backdrop';
    backdrop.id = 'op-cc-backdrop';
    document.body.appendChild(backdrop);

    const modal = document.createElement('div');
    modal.className = 'op-cc-modal';
    modal.id = 'op-cc-modal';
    modal.style.display = 'none';

    modal.innerHTML = `
      <div class="op-cc-header" id="op-cc-header">
        <div class="op-cc-title">Color Match</div>
        <div class="op-row" style="gap:6px;">
          <button class="op-button op-cc-pill" id="op-cc-realtime">Realtime: OFF</button>
          <button class="op-cc-close" id="op-cc-close" title="Close">✕</button>
        </div>
      </div>

      <div class="op-cc-body">
        <div class="op-cc-preview-wrap" style="grid-area: preview;">
          <canvas id="op-cc-preview" class="op-cc-canvas"></canvas>
          <div class="op-cc-zoom">
            <button class="op-icon-btn" id="op-cc-zoom-out" title="Zoom out">−</button>
            <button class="op-icon-btn" id="op-cc-zoom-in" title="Zoom in">+</button>
          </div>
        </div>

        <div class="op-cc-controls" style="grid-area: controls;">
          <div class="op-cc-palette" id="op-cc-free">
            <div class="op-row space">
              <label>Free Colors</label>
              <button class="op-button" id="op-cc-free-toggle">Unselect All</button>
            </div>
            <div id="op-cc-free-grid" class="op-cc-grid"></div>
          </div>

          <div class="op-cc-palette" id="op-cc-paid">
            <div class="op-row space">
              <label>Paid Colors (2000💧each)</label>
              <button class="op-button" id="op-cc-paid-toggle">Select All</button>
            </div>
            <div id="op-cc-paid-grid" class="op-cc-grid"></div>
          </div>
        </div>
      </div>

      <div class="op-cc-footer">
        <div class="op-cc-ghost" id="op-cc-meta"></div>
        <div class="op-cc-actions">
          <button class="op-button" id="op-cc-recalc" title="Recalculate color mapping">Calculate</button>
          <button class="op-button" id="op-cc-apply" title="Apply changes to overlay">Apply</button>
          <button class="op-button" id="op-cc-cancel" title="Close without saving">Cancel</button>
        </div>
      </div>
    `;
    document.body.appendChild(modal);

    const header = modal.querySelector('#op-cc-header');
    let dragStart = null;
    header.addEventListener('pointerdown', (e) => {
      if (e.target.closest('button')) return;
      dragStart = { x: e.clientX, y: e.clientY };
      header.setPointerCapture?.(e.pointerId);
    });
    header.addEventListener('pointermove', (e) => {
      if (!dragStart) return;
      const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y;
      dragStart.x = e.clientX; dragStart.y = e.clientY;
      const rect = modal.getBoundingClientRect();
      modal.style.left = rect.left + dx + rect.width/2 + 'px';
      modal.style.top  = rect.top  + dy + rect.height/2 + 'px';
      modal.style.transform = 'translate(-50%, -50%)';
    });
    header.addEventListener('pointerup', (e) => { dragStart = null; header.releasePointerCapture?.(e.pointerId); });
    header.addEventListener('pointercancel', () => { dragStart = null; });

    modal.querySelector('#op-cc-close').addEventListener('click', closeCCModal);
    modal.querySelector('#op-cc-cancel').addEventListener('click', closeCCModal);
    backdrop.addEventListener('click', closeCCModal);

    cc = {
      backdrop,
      modal,
      previewCanvas: modal.querySelector('#op-cc-preview'),
      previewCtx: modal.querySelector('#op-cc-preview').getContext('2d', { willReadFrequently: true }),

      sourceCanvas: null,
      sourceCtx: null,
      sourceImageData: null,

      processedCanvas: null,
      processedCtx: null,

      freeGrid: modal.querySelector('#op-cc-free-grid'),
      paidGrid: modal.querySelector('#op-cc-paid-grid'),
      freeToggle: modal.querySelector('#op-cc-free-toggle'),
      paidToggle: modal.querySelector('#op-cc-paid-toggle'),

      meta: modal.querySelector('#op-cc-meta'),
      applyBtn: modal.querySelector('#op-cc-apply'),
      recalcBtn: modal.querySelector('#op-cc-recalc'),
      realtimeBtn: modal.querySelector('#op-cc-realtime'),

      zoom: 1.0,
      selectedFree: new Set(config.ccFreeKeys),
      selectedPaid: new Set(config.ccPaidKeys),
      realtime: !!config.ccRealtime,

      overlay: null,
      lastColorCounts: {},
      isStale: false
    };

    const debounce = (fn, ms) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; };
    const debouncedRecalc = debounce(() => { recalcNow(); }, 120);

    cc.realtimeBtn.addEventListener('click', async () => {
      cc.realtime = !cc.realtime;
      cc.realtimeBtn.textContent = `Realtime: ${cc.realtime ? 'ON' : 'OFF'}`;
      cc.realtimeBtn.classList.toggle('op-danger', cc.realtime);
      config.ccRealtime = cc.realtime; await saveConfig(['ccRealtime']);
      if (cc.realtime && cc.isStale) recalcNow();
    });

    const zoomIn = async () => { cc.zoom = Math.min(8, (cc.zoom || 1) * 1.25); config.ccZoom = cc.zoom; await saveConfig(['ccZoom']); applyPreview(); updateMeta(); };
    const zoomOut = async () => { cc.zoom = Math.max(0.1, (cc.zoom || 1) / 1.25); config.ccZoom = cc.zoom; await saveConfig(['ccZoom']); applyPreview(); updateMeta(); };
    modal.querySelector('#op-cc-zoom-in').addEventListener('click', zoomIn);
    modal.querySelector('#op-cc-zoom-out').addEventListener('click', zoomOut);

    cc.recalcBtn.addEventListener('click', () => { recalcNow(); });

    cc.applyBtn.addEventListener('click', async () => {
      const ov = cc.overlay; if (!ov) return;

      const activePalette = getActivePalette();
      if (activePalette.length === 0) { showToast('Select at least one color.'); return; }

      if (cc.isStale) recalcNow();
      if (!cc.processedCanvas) { showToast('Nothing to apply.'); return; }
      if (cc.processedCanvas.width >= MAX_OVERLAY_DIM || cc.processedCanvas.height >= MAX_OVERLAY_DIM) {
        showToast(`Image too large to apply (must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}).`); return;
      }

      const dataUrl = cc.processedCanvas.toDataURL('image/png');
      ov.imageBase64 = dataUrl; ov.imageUrl = null; ov.isLocal = true;
      await saveConfig(['overlays']); clearOverlayCache(); ensureHook(); updateUI();

      const uniqueColors = Object.keys(cc.lastColorCounts).length;
      showToast(`Overlay updated (${cc.processedCanvas.width}×${cc.processedCanvas.height}, ${uniqueColors} colors).`);
      closeCCModal();
    });

    renderPaletteGrid();

    cc.freeToggle.addEventListener('click', async () => {
      const allActive = isAllFreeActive();
      setAllActive('free', !allActive);
      config.ccFreeKeys = Array.from(cc.selectedFree);
      await saveConfig(['ccFreeKeys']);
      if (cc.realtime) recalcNow(); else markStale();
      applyPreview(); updateMeta(); updateMasterButtons();
    });
    cc.paidToggle.addEventListener('click', async () => {
      const allActive = isAllPaidActive();
      setAllActive('paid', !allActive);
      config.ccPaidKeys = Array.from(cc.selectedPaid);
      await saveConfig(['ccPaidKeys']);
      if (cc.realtime) recalcNow(); else markStale();
      applyPreview(); updateMeta(); updateMasterButtons();
    });

    function markStale() {
      cc.isStale = true;
      cc.meta.textContent = cc.meta.textContent.replace(/ \| Status: .+$/, '') + ' | Status: pending recalculation';
    }
    function recalcNow() {
      processImage();
      cc.isStale = false;
      applyPreview();
      updateMeta();
    }
    cc._markStale = markStale;
    cc._recalcNow = recalcNow;
  }

  function openCCModal(overlay) {
    if (!cc) return;
    cc.overlay = overlay;

    document.body.classList.add('op-scroll-lock');

    cc.zoom = Number(config.ccZoom) || 1.0;
    cc.realtime = !!config.ccRealtime;
    cc.realtimeBtn.textContent = `Realtime: ${cc.realtime ? 'ON' : 'OFF'}`;
    cc.realtimeBtn.classList.toggle('op-danger', cc.realtime);

    const img = new Image();
    img.onload = () => {
      if (!cc.sourceCanvas) { cc.sourceCanvas = document.createElement('canvas'); cc.sourceCtx = cc.sourceCanvas.getContext('2d', { willReadFrequently: true }); }
      cc.sourceCanvas.width = img.width; cc.sourceCanvas.height = img.height;
      cc.sourceCtx.clearRect(0,0,img.width,img.height);
      cc.sourceCtx.drawImage(img, 0, 0);

      cc.sourceImageData = cc.sourceCtx.getImageData(0,0,img.width,img.height);

      if (!cc.processedCanvas) { cc.processedCanvas = document.createElement('canvas'); cc.processedCtx = cc.processedCanvas.getContext('2d'); }

      processImage();
      cc.isStale = false;
      applyPreview();
      updateMeta();

      cc.backdrop.classList.add('show');
      cc.modal.style.display = 'flex';
    };
    img.src = overlay.imageBase64;
  }

  function closeCCModal() {
    if (!cc) return;
    cc.backdrop.classList.remove('show');
    cc.modal.style.display = 'none';
    cc.overlay = null;
    document.body.classList.remove('op-scroll-lock');
  }

  function weightedNearest(r, g, b, palette) {
    let best = null, bestDist = Infinity;
    for (let i = 0; i < palette.length; i++) {
      const [pr, pg, pb] = palette[i];
      const rmean = (pr + r) / 2;
      const rdiff = pr - r;
      const gdiff = pg - g;
      const bdiff = pb - b;
      const x = (512 + rmean) * rdiff * rdiff >> 8;
      const y = 4 * gdiff * gdiff;
      const z = (767 - rmean) * bdiff * bdiff >> 8;
      const dist = Math.sqrt(x + y + z);
      if (dist < bestDist) { bestDist = dist; best = [pr, pg, pb]; }
    }
    return best || [0,0,0];
  }

  function getActivePalette() {
    const arr = [];
    cc.selectedFree.forEach(k => { const [r,g,b] = k.split(',').map(n => parseInt(n,10)); if (Number.isFinite(r)) arr.push([r,g,b]); });
    cc.selectedPaid.forEach(k => { const [r,g,b] = k.split(',').map(n => parseInt(n,10)); if (Number.isFinite(r)) arr.push([r,g,b]); });
    return arr;
  }

  function processImage() {
    if (!cc.sourceImageData) return;
    const w = cc.sourceImageData.width, h = cc.sourceImageData.height;

    const src = cc.sourceImageData.data;
    const out = new Uint8ClampedArray(src.length);

    const palette = getActivePalette();
    const counts = {};

    for (let i = 0; i < src.length; i += 4) {
      const r = src[i], g = src[i+1], b = src[i+2], a = src[i+3];
      if (a === 0) { out[i]=0; out[i+1]=0; out[i+2]=0; out[i+3]=0; continue; }

      const [nr, ng, nb] = palette.length ? weightedNearest(r,g,b,palette) : [r,g,b];
      out[i]=nr; out[i+1]=ng; out[i+2]=nb; out[i+3]=255;

      const key = `${nr},${ng},${nb}`;
      counts[key] = (counts[key] || 0) + 1;
    }

    if (!cc.processedCanvas) { cc.processedCanvas = document.createElement('canvas'); cc.processedCtx = cc.processedCanvas.getContext('2d'); }
    cc.processedCanvas.width = w; cc.processedCanvas.height = h;

    const outImg = new ImageData(out, w, h);
    cc.processedCtx.putImageData(outImg, 0, 0);
    cc.lastColorCounts = counts;
  }

  function applyPreview() {
    const zoom = Number(cc.zoom) || 1.0;
    const srcCanvas = cc.processedCanvas;
    if (!srcCanvas) return;

    const pw = Math.max(1, Math.round(srcCanvas.width * zoom));
    const ph = Math.max(1, Math.round(srcCanvas.height * zoom));

    cc.previewCanvas.width = pw;
    cc.previewCanvas.height = ph;

    const ctx = cc.previewCtx;
    ctx.clearRect(0,0,pw,ph);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(srcCanvas, 0,0, srcCanvas.width, srcCanvas.height, 0,0, pw, ph);
    ctx.imageSmoothingEnabled = true;
  }

  function updateMeta() {
    if (!cc.sourceImageData) { cc.meta.textContent = ''; return; }
    const w = cc.sourceImageData.width, h = cc.sourceImageData.height;
    const colorsUsed = Object.keys(cc.lastColorCounts||{}).length;
    const status = cc.isStale ? 'pending recalculation' : 'up to date';
    cc.meta.textContent = `Size: ${w}×${h} | Zoom: ${cc.zoom.toFixed(2)}× | Colors: ${colorsUsed} | Status: ${status}`;
  }

  function renderPaletteGrid() {
    cc.freeGrid.innerHTML = '';
    cc.paidGrid.innerHTML = '';

    for (const [r,g,b] of WPLACE_FREE) {
      const key = `${r},${g},${b}`;
      const cell = document.createElement('div');
      cell.className = 'op-cc-cell';
      cell.style.background = `rgb(${r},${g},${b})`;
      cell.title = WPLACE_NAMES[key] || key;
      cell.dataset.key = key;
      cell.dataset.type = 'free';
      if (cc.selectedFree.has(key)) cell.classList.add('active');
      cell.addEventListener('click', async () => {
        if (cc.selectedFree.has(key)) cc.selectedFree.delete(key); else cc.selectedFree.add(key);
        cell.classList.toggle('active', cc.selectedFree.has(key));
        config.ccFreeKeys = Array.from(cc.selectedFree); await saveConfig(['ccFreeKeys']);
        if (cc.realtime) cc._recalcNow(); else cc._markStale();
        applyPreview(); updateMeta(); updateMasterButtons();
      });
      cc.freeGrid.appendChild(cell);
    }

    for (const [r,g,b] of WPLACE_PAID) {
      const key = `${r},${g},${b}`;
      const cell = document.createElement('div');
      cell.className = 'op-cc-cell';
      cell.style.background = `rgb(${r},${g},${b})`;
      cell.title = WPLACE_NAMES[key] || key;
      cell.dataset.key = key;
      cell.dataset.type = 'paid';
      if (cc.selectedPaid.has(key)) cell.classList.add('active');
      cell.addEventListener('click', async () => {
        if (cc.selectedPaid.has(key)) cc.selectedPaid.delete(key); else cc.selectedPaid.add(key);
        cell.classList.toggle('active', cc.selectedPaid.has(key));
        config.ccPaidKeys = Array.from(cc.selectedPaid); await saveConfig(['ccPaidKeys']);
        if (cc.realtime) cc._recalcNow(); else cc._markStale();
        applyPreview(); updateMeta(); updateMasterButtons();
      });
      cc.paidGrid.appendChild(cell);
    }

    updateMasterButtons();
  }

  function updateMasterButtons() {
    cc.freeToggle.textContent = isAllFreeActive() ? 'Unselect All' : 'Select All';
    cc.paidToggle.textContent = isAllPaidActive() ? 'Unselect All' : 'Select All';
  }
  function isAllFreeActive() { return DEFAULT_FREE_KEYS.every(k => cc.selectedFree.has(k)); }
  function isAllPaidActive() {
    const allPaidKeys = WPLACE_PAID.map(([r,g,b]) => `${r},${g},${b}`);
    return allPaidKeys.every(k => cc.selectedPaid.has(k)) && allPaidKeys.length > 0;
  }
  function setAllActive(type, active) {
    if (type === 'free') {
      const keys = DEFAULT_FREE_KEYS;
      if (active) keys.forEach(k => cc.selectedFree.add(k)); else cc.selectedFree.clear();
      cc.freeGrid.querySelectorAll('.op-cc-cell').forEach(cell => cell.classList.toggle('active', active));
    } else {
      const keys = WPLACE_PAID.map(([r,g,b]) => `${r},${g},${b}`);
      if (active) keys.forEach(k => cc.selectedPaid.add(k)); else cc.selectedPaid.clear();
      cc.paidGrid.querySelectorAll('.op-cc-cell').forEach(cell => cell.classList.toggle('active', active));
    }
  }

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

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

    ensureHook();
    applyTheme();

    console.log("Overlay Pro: Initialized.");
  }

  main();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址