Wplace Overlay Pro

Overlays behind tiles on wplace.live.

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

// ==UserScript==
// @name         Wplace Overlay Pro
// @namespace    http://tampermonkey.net/
// @version      2.5.1
// @description  Overlays 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';

  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 config = {
    overlays: [],
    activeOverlayId: null,
    overlayMode: 'overlay',
    isPanelCollapsed: false,
    autoCapturePixelUrl: false,
    panelX: null,
    panelY: null,
    theme: 'light',
    collapseList: false,
    collapseEditor: false,
    collapseNudge: 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 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);
    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();
  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;
    const hImg = img.height;
    // Strictly enforce both dimensions < 1000
    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;
    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 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);
      }
    };
    hookedFetch.__overlayHooked = true;
    page.fetch = hookedFetch;
    window.fetch = hookedFetch;
    hookInstalled = true;
    console.log('Overlay Pro: Fetch hook installed.');
  }
  function detachHook() {
    if (!hookInstalled) return;
    page.fetch = NATIVE_FETCH;
    window.fetch = NATIVE_FETCH;
    hookInstalled = false;
    console.log('Overlay Pro: Fetch hook detached.');
  }

  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = `
      /* Material blue highlight */
      #overlay-pro-panel {
        --op-hl: #1e88e5;
      }

      /* Light theme (default) */
      #overlay-pro-panel {
        --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: var(--op-hl);
      }
      /* Dark theme */
      #overlay-pro-panel.op-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;
      }

      #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-top-left-radius: 16px;
        border-top-right-radius: 16px;
        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: 10px;
      }

      .op-section-title {
        display: flex; align-items: center; justify-content: space-between; width: 100%;
      }
      .op-title-left { display: inline-flex; align-items: center; gap: 8px; }
      .op-title-text { font-weight: 600; }
      .op-title-right { display: inline-flex; align-items: center; gap: 8px; }
      .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;
      }
      #overlay-pro-panel.op-dark .op-input, #overlay-pro-panel.op-dark .op-select {
        background: #10131a;
        color: var(--op-text);
        border-color: var(--op-btn-border);
      }
      .op-input[type="text"] { width: 100%; }
      .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;
      }
      #overlay-pro-panel.op-dark .op-list { background: #10131a; }
      .op-item {
        display: flex; align-items: center; gap: 6px;
        padding: 6px; border-radius: 8px;
        border: 1px solid var(--op-border); background: var(--op-subtle);
      }
      #overlay-pro-panel.op-dark .op-item { background: #151922; }
      .op-item.active {
        outline: 2px solid color-mix(in oklab, var(--op-accent) 35%, transparent);
        background: #fff;
      }
      #overlay-pro-panel.op-dark .op-item.active { background: #10131a; }
      .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;
      }
      #overlay-pro-panel.op-dark .op-preview { background: #10131a; }
      .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-nudge-row { display: flex; gap: 8px; justify-content: right; }

      /* Danger styles (when placing) */
      .op-danger { background: #fee2e2; border-color: #fecaca; color: #7f1d1d; }
      #overlay-pro-panel.op-dark .op-danger { background: rgba(220,38,38,0.15); border-color: rgba(220,38,38,0.35); color: #fecaca; }
      .op-danger-text { color: #dc2626; font-weight: 600; }
      #overlay-pro-panel.op-dark .op-danger-text { color: #fca5a5; }

      /* Toasts (theme-aware) */
      .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-stack.op-dark .op-toast {
        background: rgba(20,23,29,0.98);
        border-color: #2a2f3a;
        color: #f5f6f9;
      }
      .op-toast.show { opacity: 1; transform: translateY(0); }
    `;
    document.head.appendChild(style);
  }

  function createUI() {
    if (document.getElementById('overlay-pro-panel')) return;
    const panel = document.createElement('div');
    panel.id = 'overlay-pro-panel';
    if (config.theme === 'dark') panel.classList.add('op-dark');

    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>
              </div>
              <button class="op-chevron" id="op-collapse-list" title="Collapse/Expand">▾</button>
            </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"><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">
              <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);
    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 baseName = 'Overlay';
    const name = uniqueName(baseName);
    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 baseName = item.name || 'Imported Overlay';
      const name = uniqueName(baseName);
      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();
    });
  }

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

    let isDragging = false;
    let startX = 0, startY = 0, startLeft = 0, startTop = 0;
    let moved = false;
    const clamp = (val, min, max) => Math.min(Math.max(val, 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;
      const dy = e.clientY - startY;
      const maxLeft = Math.max(8, window.innerWidth - panel.offsetWidth - 8);
      const maxTop = Math.max(8, window.innerHeight - panel.offsetHeight - 8);
      const newLeft = clamp(startLeft + dx, 8, maxLeft);
      const newTop = clamp(startTop + dy, 8, maxTop);
      panel.style.left = newLeft + 'px';
      panel.style.top = newTop + '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() {
    const panel = document.getElementById('overlay-pro-panel');
    if (!panel) return;
    panel.classList.toggle('op-dark', 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');

    if (ov.imageBase64) {
      srcWrap.style.display = 'none';
      previewWrap.style.display = 'flex';
      previewImg.src = ov.imageBase64;
    } else {
      srcWrap.style.display = 'block';
      previewWrap.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 off = `Offset X ${ov.offsetX}, Y ${ov.offsetY}`;
    const indicator = document.getElementById('op-offset-indicator');
    if (indicator) indicator.textContent = off;

    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';
  }

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

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

    ensureHook();

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

  main();
})();

QingJ © 2025

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