// ==UserScript==
// @name Wplace Overlay Pro
// @namespace http://tampermonkey.net/
// @version 2.7.1
// @description Overlays tiles on wplace.live. Can also resize, and 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 createHTMLCanvas(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 canvasToDataURLSafe(canvas) {
if (canvas && typeof canvas.toDataURL === 'function') return canvas.toDataURL('image/png');
if (canvas && typeof canvas.convertToBlob === 'function') { const blob = await canvas.convertToBlob(); return await blobToDataURL(blob); }
if (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas) {
const bmp = canvas.transferToImageBitmap?.();
if (bmp) { const html = createHTMLCanvas(canvas.width, canvas.height); const ctx = html.getContext('2d'); ctx.drawImage(bmp, 0, 0); return html.toDataURL('image/png'); }
}
throw new Error('Cannot export canvas to data URL');
}
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: var(--op-bg); 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: var(--op-bg); }
.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: var(--op-bg); }
.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: var(--op-bg); 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); }
.op-toast-stack.op-dark .op-toast { background: rgba(27,30,36,0.98); border-color: #2a2f3a; color: #f5f6f9; }
/* Color Match Modal (static centered; not draggable) */
.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(1280px, 98vw);
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; 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; user-select: none; cursor: default; }
.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: 2fr 420px;
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; }
/* Resize Modal (static centered; not draggable) */
.op-rs-backdrop { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.45); display: none; }
.op-rs-backdrop.show { display: block; }
.op-rs-modal {
position: fixed; z-index: 10001;
width: min(1200px, 96vw);
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; flex-direction: column;
max-height: 92vh;
}
.op-rs-header { padding: 10px 12px; border-bottom: 1px solid var(--op-border); display: flex; align-items: center; justify-content: space-between; user-select: none; cursor: default; }
.op-rs-title { font-weight: 600; }
.op-rs-close { border: 1px solid var(--op-border); background: transparent; border-radius: 8px; padding: 4px 8px; cursor: pointer; }
.op-rs-close:hover { background: var(--op-btn); }
.op-rs-tabs { display: flex; gap: 6px; padding: 8px 12px 0 12px; }
.op-rs-tab-btn { background: var(--op-btn); color: var(--op-text); border: 1px solid var(--op-btn-border); border-radius: 10px; padding: 6px 10px; cursor: pointer; }
.op-rs-tab-btn.active { outline: 2px solid color-mix(in oklab, var(--op-accent) 35%, transparent); background: var(--op-btn-hover); }
.op-rs-body { padding: 12px; display: grid; grid-template-columns: 1fr; gap: 10px; overflow: auto; }
.op-rs-row { display: flex; align-items: center; gap: 8px; }
.op-rs-row .op-input { flex: 1; }
.op-rs-pane { display: none; }
.op-rs-pane.show { display: block; }
.op-rs-preview-wrap { background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; position: relative; height: clamp(260px, 36vh, 540px); display: flex; align-items: center; justify-content: center; overflow: hidden; }
.op-rs-canvas { image-rendering: pixelated; }
.op-rs-zoom { position: absolute; top: 8px; right: 8px; display: inline-flex; gap: 6px; }
.op-rs-grid-note { color: var(--op-muted); font-size: 12px; }
.op-rs-mini { width: 96px; }
.op-rs-dual { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; width: 100%; height: 100%; padding: 8px; box-sizing: border-box; }
.op-rs-col { position: relative; background: var(--op-bg); border: 1px dashed var(--op-border); border-radius: 10px; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; overflow: hidden; }
.op-rs-col .label { position: absolute; top: 2px; left: 0; right: 0; text-align: center; font-size: 12px; color: var(--op-muted); pointer-events: none; }
.op-rs-col .pad-top { height: 18px; width: 100%; flex: 0 0 auto; }
.op-rs-thumb { width: 100%; height: calc(100% - 18px); display: block; }
.op-pan-grab { cursor: grab; }
.op-pan-grabbing { cursor: grabbing; }
.op-rs-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; }
`;
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; gap:8px; flex-wrap:wrap;">
<button class="op-button" id="op-download-overlay" title="Download this overlay image">Download</button>
<button class="op-button" id="op-open-resize" title="Resize the overlay image">Resize</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();
buildRSModal();
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);
});
const resizeBtn = $('op-open-resize');
if (resizeBtn) {
resizeBtn.addEventListener('click', () => {
const ov = getActiveOverlay();
if (!ov || !ov.imageBase64) { showToast('No overlay image to resize.'); return; }
openRSModal(ov);
});
}
window.addEventListener('resize', () => {
// nothing UI-critical beyond modals
});
}
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);
// No drag handlers (static centered)
modal.querySelector('#op-cc-close').addEventListener('click', closeCCModal);
backdrop.addEventListener('click', closeCCModal);
modal.querySelector('#op-cc-cancel').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
};
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();
}
}
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) processImage(); else { cc.isStale = true; }
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) processImage(); else { cc.isStale = true; }
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));
}
}
// Resize modal (Simple + Advanced grid)
let rs = null;
function buildRSModal() {
const backdrop = document.createElement('div');
backdrop.className = 'op-rs-backdrop';
backdrop.id = 'op-rs-backdrop';
document.body.appendChild(backdrop);
const modal = document.createElement('div');
modal.className = 'op-rs-modal';
modal.id = 'op-rs-modal';
modal.style.display = 'none';
modal.innerHTML = `
<div class="op-rs-header" id="op-rs-header">
<div class="op-rs-title">Resize Overlay</div>
<button class="op-rs-close" id="op-rs-close" title="Close">✕</button>
</div>
<div class="op-rs-tabs">
<button class="op-rs-tab-btn active" id="op-rs-tab-simple">Simple</button>
<button class="op-rs-tab-btn" id="op-rs-tab-advanced">Advanced (grid)</button>
</div>
<div class="op-rs-body">
<!-- SIMPLE PANE -->
<div class="op-rs-pane show" id="op-rs-pane-simple">
<div class="op-rs-row">
<label style="width:110px;">Original</label>
<input type="text" class="op-input" id="op-rs-orig" disabled>
</div>
<div class="op-rs-row">
<label style="width:110px;">Width</label>
<input type="number" min="1" step="1" class="op-input" id="op-rs-w">
</div>
<div class="op-rs-row">
<label style="width:110px;">Height</label>
<input type="number" min="1" step="1" class="op-input" id="op-rs-h">
</div>
<div class="op-rs-row">
<input type="checkbox" id="op-rs-lock" checked>
<label for="op-rs-lock">Lock aspect ratio</label>
</div>
<div class="op-rs-row" style="gap:6px; flex-wrap:wrap;">
<label style="width:110px;">Quick</label>
<button class="op-button" id="op-rs-double">2x</button>
<button class="op-button" id="op-rs-onex">1x</button>
<button class="op-button" id="op-rs-half">0.5x</button>
<button class="op-button" id="op-rs-third">0.33x</button>
<button class="op-button" id="op-rs-quarter">0.25x</button>
</div>
<div class="op-rs-row">
<label style="width:110px;">Scale factor</label>
<input type="number" step="0.01" min="0.01" class="op-input" id="op-rs-scale" placeholder="e.g. 0.5">
<button class="op-button" id="op-rs-apply-scale">Apply</button>
</div>
<!-- Simple mode side-by-side preview -->
<div class="op-rs-preview-wrap" id="op-rs-sim-wrap">
<div class="op-rs-dual">
<div class="op-rs-col" id="op-rs-col-left">
<div class="label">Original</div>
<div class="pad-top"></div>
<canvas id="op-rs-sim-orig" class="op-rs-canvas op-rs-thumb"></canvas>
</div>
<div class="op-rs-col" id="op-rs-col-right">
<div class="label">Result (downscale → upscale preview)</div>
<div class="pad-top"></div>
<canvas id="op-rs-sim-new" class="op-rs-canvas op-rs-thumb"></canvas>
</div>
</div>
</div>
</div>
<!-- ADVANCED PANE -->
<div class="op-rs-pane" id="op-rs-pane-advanced">
<div class="op-rs-preview-wrap op-pan-grab" id="op-rs-adv-wrap">
<canvas id="op-rs-preview" class="op-rs-canvas"></canvas>
<div class="op-rs-zoom">
<button class="op-icon-btn" id="op-rs-zoom-out" title="Zoom out">−</button>
<button class="op-icon-btn" id="op-rs-zoom-in" title="Zoom in">+</button>
</div>
</div>
<div class="op-rs-row" style="margin-top:8px;">
<label style="width:160px;">Multiplier</label>
<input type="range" id="op-rs-mult-range" min="1" max="64" step="0.1" style="flex:1;">
<input type="number" id="op-rs-mult-input" class="op-input op-rs-mini" min="1" step="0.05">
</div>
<div class="op-rs-row">
<input type="checkbox" id="op-rs-bind" checked>
<label for="op-rs-bind">Bind X/Y block sizes</label>
</div>
<div class="op-rs-row">
<label style="width:160px;">Block W / H</label>
<input type="number" id="op-rs-blockw" class="op-input op-rs-mini" min="1" step="0.1">
<input type="number" id="op-rs-blockh" class="op-input op-rs-mini" min="1" step="0.1">
</div>
<div class="op-rs-row">
<label style="width:160px;">Offset X / Y</label>
<input type="number" id="op-rs-offx" class="op-input op-rs-mini" min="0" step="0.1">
<input type="number" id="op-rs-offy" class="op-input op-rs-mini" min="0" step="0.1">
</div>
<div class="op-rs-row">
<label style="width:160px;">Dot radius</label>
<input type="range" id="op-rs-dotr" min="1" max="8" step="1" style="flex:1;">
<span id="op-rs-dotr-val" class="op-muted" style="width:36px; text-align:right;"></span>
</div>
<div class="op-rs-row">
<input type="checkbox" id="op-rs-grid" checked>
<label for="op-rs-grid">Show grid wireframe</label>
</div>
<div class="op-rs-grid-note" id="op-rs-adv-note">Align red dots to block centers. Drag to pan; use buttons or Ctrl+wheel to zoom.</div>
<!-- Calculated result preview (to scale) -->
<div class="op-rs-row" style="margin-top:8px;">
<label style="width:160px;">Calculated preview</label>
<span class="op-muted" id="op-rs-adv-resmeta"></span>
</div>
<div class="op-rs-preview-wrap" id="op-rs-adv-result-wrap" style="height: clamp(200px, 26vh, 420px);">
<canvas id="op-rs-adv-result" class="op-rs-canvas"></canvas>
</div>
</div>
</div>
<div class="op-rs-footer">
<div class="op-cc-ghost" id="op-rs-meta">Nearest-neighbor OR grid center sampling; alpha hardened (no semi-transparent pixels).</div>
<div class="op-cc-actions">
<button class="op-button" id="op-rs-calc">Calculate</button>
<button class="op-button" id="op-rs-apply">Apply</button>
<button class="op-button" id="op-rs-cancel">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
// No drag handlers (static centered)
const els = {
backdrop,
modal,
// tabs
tabSimple: modal.querySelector('#op-rs-tab-simple'),
tabAdvanced: modal.querySelector('#op-rs-tab-advanced'),
paneSimple: modal.querySelector('#op-rs-pane-simple'),
paneAdvanced: modal.querySelector('#op-rs-pane-advanced'),
// simple
orig: modal.querySelector('#op-rs-orig'),
w: modal.querySelector('#op-rs-w'),
h: modal.querySelector('#op-rs-h'),
lock: modal.querySelector('#op-rs-lock'),
note: modal.querySelector('#op-rs-note'),
onex: modal.querySelector('#op-rs-onex'),
half: modal.querySelector('#op-rs-half'),
third: modal.querySelector('#op-rs-third'),
quarter: modal.querySelector('#op-rs-quarter'),
double: modal.querySelector('#op-rs-double'),
scale: modal.querySelector('#op-rs-scale'),
applyScale: modal.querySelector('#op-rs-apply-scale'),
simWrap: modal.querySelector('#op-rs-sim-wrap'),
simOrig: modal.querySelector('#op-rs-sim-orig'),
simNew: modal.querySelector('#op-rs-sim-new'),
colLeft: modal.querySelector('#op-rs-col-left'),
colRight: modal.querySelector('#op-rs-col-right'),
// advanced
advWrap: modal.querySelector('#op-rs-adv-wrap'),
preview: modal.querySelector('#op-rs-preview'),
meta: modal.querySelector('#op-rs-meta'),
zoomIn: modal.querySelector('#op-rs-zoom-in'),
zoomOut: modal.querySelector('#op-rs-zoom-out'),
multRange: modal.querySelector('#op-rs-mult-range'),
multInput: modal.querySelector('#op-rs-mult-input'),
bind: modal.querySelector('#op-rs-bind'),
blockW: modal.querySelector('#op-rs-blockw'),
blockH: modal.querySelector('#op-rs-blockh'),
offX: modal.querySelector('#op-rs-offx'),
offY: modal.querySelector('#op-rs-offy'),
dotR: modal.querySelector('#op-rs-dotr'),
dotRVal: modal.querySelector('#op-rs-dotr-val'),
gridToggle: modal.querySelector('#op-rs-grid'),
advNote: modal.querySelector('#op-rs-adv-note'),
// advanced result
resWrap: modal.querySelector('#op-rs-adv-result-wrap'),
resCanvas: modal.querySelector('#op-rs-adv-result'),
resMeta: modal.querySelector('#op-rs-adv-resmeta'),
// footer
calcBtn: modal.querySelector('#op-rs-calc'),
applyBtn: modal.querySelector('#op-rs-apply'),
cancelBtn: modal.querySelector('#op-rs-cancel'),
closeBtn: modal.querySelector('#op-rs-close'),
};
const ctxPrev = els.preview.getContext('2d', { willReadFrequently: true });
const ctxSimOrig = els.simOrig.getContext('2d', { willReadFrequently: true });
const ctxSimNew = els.simNew.getContext('2d', { willReadFrequently: true });
const ctxRes = els.resCanvas.getContext('2d', { willReadFrequently: true });
rs = {
...els,
ov: null,
img: null,
origW: 0, origH: 0,
mode: 'simple',
zoom: 1.0,
updating: false,
mult: 4,
gapX: 4, gapY: 4,
offx: 0, offy: 0,
dotr: 1,
viewX: 0, viewY: 0,
panning: false,
panStart: null,
// calculated result (advanced)
calcCanvas: null,
calcCols: 0,
calcRows: 0,
calcReady: false,
};
// Footer helpers
const computeSimpleFooterText = () => {
const W = parseInt(rs.w.value||'0',10);
const H = parseInt(rs.h.value||'0',10);
const ok = Number.isFinite(W) && Number.isFinite(H) && W>0 && H>0;
const limit = (W >= MAX_OVERLAY_DIM || H >= MAX_OVERLAY_DIM);
return ok ? (limit ? `Target: ${W}×${H} (exceeds limit: must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})`
: `Target: ${W}×${H} (OK)`)
: 'Enter positive width and height.';
};
const computeAdvancedFooterText = () => {
const { cols, rows } = sampleDims();
const limit = (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM);
return (cols>0 && rows>0)
? `Samples: ${cols} × ${rows} | Output: ${cols}×${rows}${limit ? ` (exceeds limit: < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})` : ''}`
: 'Adjust multiplier/offset until dots sit at centers.';
};
const updateFooterMeta = () => {
rs.meta.textContent = (rs.mode === 'advanced') ? computeAdvancedFooterText() : computeSimpleFooterText();
};
const syncSimpleNote = () => {
const W = parseInt(rs.w.value||'0',10);
const H = parseInt(rs.h.value||'0',10);
const ok = Number.isFinite(W) && Number.isFinite(H) && W>0 && H>0;
const limit = (W >= MAX_OVERLAY_DIM || H >= MAX_OVERLAY_DIM);
const simpleText = ok
? (limit ? `Target: ${W}×${H} (exceeds limit: must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})`
: `Target: ${W}×${H} (OK)`)
: 'Enter positive width and height.';
if (rs.note) rs.note.textContent = simpleText;
if (rs.mode === 'simple') rs.applyBtn.disabled = (!ok || limit);
if (rs.mode === 'simple') rs.meta.textContent = simpleText;
};
function applyScaleToFields(scale) {
const W = Math.max(1, Math.round(rs.origW * scale));
const H = Math.max(1, Math.round(rs.origH * scale));
rs.updating = true;
rs.w.value = W;
rs.h.value = rs.lock.checked ? Math.max(1, Math.round(W * rs.origH / rs.origW)) : H;
rs.updating = false;
syncSimpleNote();
}
function drawSimplePreview() {
if (!rs.img) return;
const leftLabelH = rs.colLeft.querySelector('.pad-top').offsetHeight;
const rightLabelH = rs.colRight.querySelector('.pad-top').offsetHeight;
const leftW = rs.colLeft.clientWidth;
const rightW = rs.colRight.clientWidth;
const leftH = rs.colLeft.clientHeight - leftLabelH;
const rightH = rs.colRight.clientHeight - rightLabelH;
rs.simOrig.width = leftW; rs.simOrig.height = leftH;
rs.simNew.width = rightW; rs.simNew.height = rightH;
// Draw original
ctxSimOrig.save();
ctxSimOrig.imageSmoothingEnabled = false;
ctxSimOrig.clearRect(0,0,leftW,leftH);
const sFit = Math.min(leftW / rs.origW, leftH / rs.origH);
const dW = Math.max(1, Math.floor(rs.origW * sFit));
const dH = Math.max(1, Math.floor(rs.origH * sFit));
const dx0 = Math.floor((leftW - dW) / 2);
const dy0 = Math.floor((leftH - dH) / 2);
ctxSimOrig.drawImage(rs.img, 0,0, rs.origW,rs.origH, dx0,dy0, dW,dH);
ctxSimOrig.restore();
// Draw downscale -> upscale preview
const W = parseInt(rs.w.value||'0',10);
const H = parseInt(rs.h.value||'0',10);
ctxSimNew.save();
ctxSimNew.imageSmoothingEnabled = false;
ctxSimNew.clearRect(0,0,rightW,rightH);
if (Number.isFinite(W) && Number.isFinite(H) && W>0 && H>0) {
const tiny = createCanvas(W, H);
const tctx = tiny.getContext('2d', { willReadFrequently: true });
tctx.imageSmoothingEnabled = false;
tctx.clearRect(0,0,W,H);
tctx.drawImage(rs.img, 0,0, rs.origW,rs.origH, 0,0, W,H);
const id = tctx.getImageData(0,0,W,H);
const data = id.data;
for (let i=0;i<data.length;i+=4) { if (data[i+3] !== 0) data[i+3]=255; }
tctx.putImageData(id, 0, 0);
const s2 = Math.min(rightW / W, rightH / H);
const dW2 = Math.max(1, Math.floor(W * s2));
const dH2 = Math.max(1, Math.floor(H * s2));
const dx2 = Math.floor((rightW - dW2)/2);
const dy2 = Math.floor((rightH - dH2)/2);
ctxSimNew.drawImage(tiny, 0,0, W,H, dx2,dy2, dW2,dH2);
} else {
ctxSimNew.drawImage(rs.img, 0,0, rs.origW,rs.origH, dx0,dy0, dW,dH);
}
ctxSimNew.restore();
}
const clampFloat = (v, min, max) => {
v = parseFloat(v);
if (!Number.isFinite(v)) return undefined; // signal invalid
return Math.min(Math.max(v, min), max);
};
const syncAdvFieldsToState = () => {
rs.updating = true;
rs.multRange.value = String(rs.mult);
rs.multInput.value = String(rs.mult);
rs.blockW.value = String(rs.gapX);
rs.blockH.value = String(rs.gapY);
rs.offX.value = String(rs.offx);
rs.offY.value = String(rs.offy);
rs.dotR.value = String(rs.dotr);
rs.dotRVal.textContent = String(rs.dotr);
rs.updating = false;
};
function sampleDims() {
const cols = Math.floor((rs.origW - rs.offx) / rs.gapX);
const rows = Math.floor((rs.origH - rs.offy) / rs.gapY);
return { cols: Math.max(0, cols), rows: Math.max(0, rows) };
}
function syncAdvancedMeta() {
const { cols, rows } = sampleDims();
const limit = (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM);
if (rs.mode === 'advanced') {
rs.applyBtn.disabled = !rs.calcReady;
} else {
const W = parseInt(rs.w.value||'0',10), H = parseInt(rs.h.value||'0',10);
const ok = Number.isFinite(W)&&Number.isFinite(H)&&W>0&&H>0&&W<MAX_OVERLAY_DIM&&H<MAX_OVERLAY_DIM;
rs.applyBtn.disabled = !ok;
}
updateFooterMeta();
}
function drawAdvancedPreview() {
if (rs.mode !== 'advanced' || !rs.img) return;
const w = rs.origW, h = rs.origH;
const destW = Math.max(50, Math.floor(rs.advWrap.clientWidth));
const destH = Math.max(50, Math.floor(rs.advWrap.clientHeight));
rs.preview.width = destW;
rs.preview.height = destH;
const sw = Math.max(1, Math.floor(destW / rs.zoom));
const sh = Math.max(1, Math.floor(destH / rs.zoom));
const maxX = Math.max(0, w - sw);
const maxY = Math.max(0, h - sh);
rs.viewX = Math.min(Math.max(0, rs.viewX), maxX);
rs.viewY = Math.min(Math.max(0, rs.viewY), maxY);
ctxPrev.save();
ctxPrev.imageSmoothingEnabled = false;
ctxPrev.clearRect(0,0,destW,destH);
ctxPrev.drawImage(rs.img, rs.viewX, rs.viewY, sw, sh, 0, 0, destW, destH);
// grid wireframe
if (rs.gridToggle.checked && rs.gapX >= 1 && rs.gapY >= 1) {
ctxPrev.strokeStyle = 'rgba(255,59,48,0.45)';
ctxPrev.lineWidth = 1;
const startGX = Math.ceil((rs.viewX - rs.offx) / rs.gapX);
const endGX = Math.floor((rs.viewX + sw - rs.offx) / rs.gapX);
const startGY = Math.ceil((rs.viewY - rs.offy) / rs.gapY);
const endGY = Math.floor((rs.viewY + sh - rs.offy) / rs.gapY);
const linesX = Math.max(0, endGX - startGX + 1);
const linesY = Math.max(0, endGY - startGY + 1);
// Safety: don't try to draw insane amounts
if (linesX <= 4000 && linesY <= 4000) {
ctxPrev.beginPath();
for (let gx = startGX; gx <= endGX; gx++) {
const x = rs.offx + gx * rs.gapX;
const px = Math.round((x - rs.viewX) * rs.zoom);
ctxPrev.moveTo(px + 0.5, 0);
ctxPrev.lineTo(px + 0.5, destH);
}
for (let gy = startGY; gy <= endGY; gy++) {
const y = rs.offy + gy * rs.gapY;
const py = Math.round((y - rs.viewY) * rs.zoom);
ctxPrev.moveTo(0, py + 0.5);
ctxPrev.lineTo(destW, py + 0.5);
}
ctxPrev.stroke();
}
}
// dot overlay
if (rs.gapX >= 1 && rs.gapY >= 1) {
ctxPrev.fillStyle = '#ff3b30';
const cx0 = rs.offx + Math.floor(rs.gapX/2);
const cy0 = rs.offy + Math.floor(rs.gapY/2);
if (cx0 >= 0 && cy0 >= 0) {
const startX = Math.ceil((rs.viewX - cx0) / rs.gapX);
const startY = Math.ceil((rs.viewY - cy0) / rs.gapY);
const endY = Math.floor((rs.viewY + sh - 1 - cy0) / rs.gapY);
const endX2 = Math.floor((rs.viewX + sw - 1 - cx0) / rs.gapX);
const r = rs.dotr;
const dotsX = Math.max(0, endX2 - startX + 1);
const dotsY = Math.max(0, endY - startY + 1);
// Safety: avoid drawing absurd numbers of arcs
const maxDots = 300000;
if (dotsX * dotsY <= maxDots) {
for (let gy = startY; gy <= endY; gy++) {
const y = cy0 + gy * rs.gapY;
for (let gx = startX; gx <= endX2; gx++) {
const x = cx0 + gx * rs.gapX;
const px = Math.round((x - rs.viewX) * rs.zoom);
const py = Math.round((y - rs.viewY) * rs.zoom);
ctxPrev.beginPath();
ctxPrev.arc(px, py, r, 0, Math.PI*2);
ctxPrev.fill();
}
}
}
}
}
ctxPrev.restore();
}
function drawAdvancedResultPreview() {
const canvas = rs.calcCanvas;
const wrap = rs.resWrap;
if (!wrap || !canvas) {
ctxRes.clearRect(0,0, rs.resCanvas.width, rs.resCanvas.height);
rs.resMeta.textContent = 'No result. Click Calculate.';
return;
}
const W = canvas.width, H = canvas.height;
const availW = Math.max(50, Math.floor(wrap.clientWidth - 16));
const availH = Math.max(50, Math.floor(wrap.clientHeight - 16));
const s = Math.min(availW / W, availH / H);
const dW = Math.max(1, Math.floor(W * s));
const dH = Math.max(1, Math.floor(H * s));
rs.resCanvas.width = dW;
rs.resCanvas.height = dH;
ctxRes.save();
ctxRes.imageSmoothingEnabled = false;
ctxRes.clearRect(0,0,dW,dH);
ctxRes.drawImage(canvas, 0,0, W,H, 0,0, dW,dH);
ctxRes.restore();
rs.resMeta.textContent = `Output: ${W}×${H}${(W>=MAX_OVERLAY_DIM||H>=MAX_OVERLAY_DIM) ? ` (exceeds limit: < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})` : ''}`;
}
// expose for external use
rs._drawSimplePreview = drawSimplePreview;
rs._drawAdvancedPreview = drawAdvancedPreview;
rs._drawAdvancedResultPreview = drawAdvancedResultPreview;
const setMode = (m) => {
rs.mode = m;
rs.tabSimple.classList.toggle('active', m === 'simple');
rs.tabAdvanced.classList.toggle('active', m === 'advanced');
rs.paneSimple.classList.toggle('show', m === 'simple');
rs.paneAdvanced.classList.toggle('show', m === 'advanced');
updateFooterMeta();
rs.calcBtn.style.display = (m === 'advanced') ? 'inline-block' : 'none';
if (m === 'advanced') {
rs.applyBtn.disabled = !rs.calcReady;
} else {
syncSimpleNote();
}
syncAdvancedMeta();
if (m === 'advanced') {
drawAdvancedPreview();
drawAdvancedResultPreview();
} else {
drawSimplePreview();
}
};
rs.tabSimple.addEventListener('click', () => setMode('simple'));
rs.tabAdvanced.addEventListener('click', () => setMode('advanced'));
// SIMPLE handlers
const onWidthInput = () => {
if (rs.updating) return;
rs.updating = true;
const W = parseInt(rs.w.value||'0',10);
if (rs.lock.checked && rs.origW>0 && rs.origH>0 && W>0) {
rs.h.value = Math.max(1, Math.round(W * rs.origH / rs.origW));
}
rs.updating = false;
syncSimpleNote();
if (rs.mode === 'simple') drawSimplePreview();
};
const onHeightInput = () => {
if (rs.updating) return;
rs.updating = true;
const H = parseInt(rs.h.value||'0',10);
if (rs.lock.checked && rs.origW>0 && rs.origH>0 && H>0) {
rs.w.value = Math.max(1, Math.round(H * rs.origW / rs.origH));
}
rs.updating = false;
syncSimpleNote();
if (rs.mode === 'simple') drawSimplePreview();
};
rs.w.addEventListener('input', onWidthInput);
rs.h.addEventListener('input', onHeightInput);
rs.onex.addEventListener('click', () => { applyScaleToFields(1); drawSimplePreview(); });
rs.half.addEventListener('click', () => { applyScaleToFields(0.5); drawSimplePreview(); });
rs.third.addEventListener('click', () => { applyScaleToFields(1/3); drawSimplePreview(); });
rs.quarter.addEventListener('click', () => { applyScaleToFields(1/4); drawSimplePreview(); });
rs.double.addEventListener('click', () => { applyScaleToFields(2); drawSimplePreview(); });
rs.applyScale.addEventListener('click', () => {
const s = parseFloat(rs.scale.value||'');
if (!Number.isFinite(s) || s<=0) { showToast('Enter a valid scale factor > 0'); return; }
applyScaleToFields(s);
drawSimplePreview();
});
// Advanced: mark calculation stale on param changes
const markCalcStale = () => {
if (rs.mode === 'advanced') {
rs.calcReady = false;
rs.applyBtn.disabled = true;
drawAdvancedResultPreview();
updateFooterMeta();
}
};
const onMultChange = (v) => {
if (rs.updating) return;
const parsed = parseFloat(v);
if (!Number.isFinite(parsed)) return; // ignore invalid/partial input while typing
const clamped = Math.min(Math.max(parsed, 1), 128);
rs.mult = clamped;
if (rs.bind.checked) { rs.gapX = clamped; rs.gapY = clamped; }
syncAdvFieldsToState();
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
};
rs.multRange.addEventListener('input', (e) => { if (rs.updating) return; onMultChange(e.target.value); });
rs.multInput.addEventListener('input', (e) => {
if (rs.updating) return;
const v = e.target.value;
if (!Number.isFinite(parseFloat(v))) return;
onMultChange(v);
});
rs.bind.addEventListener('change', () => {
if (rs.bind.checked) { rs.gapX = rs.mult; rs.gapY = rs.mult; syncAdvFieldsToState(); }
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.blockW.addEventListener('input', (e) => {
if (rs.updating) return;
const raw = e.target.value;
const val = parseFloat(raw);
if (!Number.isFinite(val)) return; // ignore invalid characters while typing
rs.gapX = Math.min(Math.max(val, 1), 4096);
if (rs.bind.checked) { rs.mult = rs.gapX; rs.gapY = rs.gapX; }
syncAdvFieldsToState();
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.blockH.addEventListener('input', (e) => {
if (rs.updating) return;
const raw = e.target.value;
const val = parseFloat(raw);
if (!Number.isFinite(val)) return;
rs.gapY = Math.min(Math.max(val, 1), 4096);
if (rs.bind.checked) { rs.mult = rs.gapY; rs.gapX = rs.gapY; }
syncAdvFieldsToState();
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.offX.addEventListener('input', (e) => {
const raw = e.target.value;
const val = parseFloat(raw);
if (!Number.isFinite(val)) return;
rs.offx = Math.min(Math.max(val, 0), Math.max(0, rs.origW-0.0001));
rs.viewX = Math.min(rs.viewX, Math.max(0, rs.origW - 1));
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.offY.addEventListener('input', (e) => {
const raw = e.target.value;
const val = parseFloat(raw);
if (!Number.isFinite(val)) return;
rs.offy = Math.min(Math.max(val, 0), Math.max(0, rs.origH-0.0001));
rs.viewY = Math.min(rs.viewY, Math.max(0, rs.origH - 1));
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.dotR.addEventListener('input', (e) => {
rs.dotr = Math.max(1, Math.round(Number(e.target.value)||1));
rs.dotRVal.textContent = String(rs.dotr);
drawAdvancedPreview();
});
rs.gridToggle.addEventListener('change', drawAdvancedPreview);
// Zoom buttons keep center point
const applyZoom = (factor) => {
const destW = Math.max(50, Math.floor(rs.advWrap.clientWidth));
const destH = Math.max(50, Math.floor(rs.advWrap.clientHeight));
const sw = Math.max(1, Math.floor(destW / rs.zoom));
const sh = Math.max(1, Math.floor(destH / rs.zoom));
const cx = rs.viewX + sw / 2;
const cy = rs.viewY + sh / 2;
rs.zoom = Math.min(32, Math.max(0.1, rs.zoom * factor));
const sw2 = Math.max(1, Math.floor(destW / rs.zoom));
const sh2 = Math.max(1, Math.floor(destH / rs.zoom));
rs.viewX = Math.min(Math.max(0, Math.round(cx - sw2 / 2)), Math.max(0, rs.origW - sw2));
rs.viewY = Math.min(Math.max(0, Math.round(cy - sh2 / 2)), Math.max(0, rs.origH - sh2));
drawAdvancedPreview();
};
rs.zoomIn.addEventListener('click', () => applyZoom(1.25));
rs.zoomOut.addEventListener('click', () => applyZoom(1/1.25));
rs.advWrap.addEventListener('wheel', (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaY || 0;
applyZoom(delta > 0 ? 1/1.15 : 1.15);
}, { passive: false });
// PANNING
const onPanDown = (e) => {
if (e.target.closest('.op-rs-zoom')) return;
rs.panning = true;
rs.panStart = { x: e.clientX, y: e.clientY, viewX: rs.viewX, viewY: rs.viewY };
rs.advWrap.classList.remove('op-pan-grab');
rs.advWrap.classList.add('op-pan-grabbing');
rs.advWrap.setPointerCapture?.(e.pointerId);
};
const onPanMove = (e) => {
if (!rs.panning) return;
const dx = e.clientX - rs.panStart.x;
const dy = e.clientY - rs.panStart.y;
const wrapW = rs.advWrap.clientWidth;
const wrapH = rs.advWrap.clientHeight;
const sw = Math.max(1, Math.floor(wrapW / rs.zoom));
const sh = Math.max(1, Math.floor(wrapH / rs.zoom));
let nx = rs.panStart.viewX - Math.round(dx / rs.zoom);
let ny = rs.panStart.viewY - Math.round(dy / rs.zoom);
nx = Math.min(Math.max(0, nx), Math.max(0, rs.origW - sw));
ny = Math.min(Math.max(0, ny), Math.max(0, rs.origH - sh));
rs.viewX = nx;
rs.viewY = ny;
drawAdvancedPreview();
};
const onPanUp = (e) => {
if (!rs.panning) return;
rs.panning = false;
rs.panStart = null;
rs.advWrap.classList.remove('op-pan-grabbing');
rs.advWrap.classList.add('op-pan-grab');
rs.advWrap.releasePointerCapture?.(e.pointerId);
};
rs.advWrap.addEventListener('pointerdown', onPanDown);
rs.advWrap.addEventListener('pointermove', onPanMove);
rs.advWrap.addEventListener('pointerup', onPanUp);
rs.advWrap.addEventListener('pointercancel', onPanUp);
rs.advWrap.addEventListener('pointerleave', onPanUp);
// Footer buttons
const close = () => closeRSModal();
rs.cancelBtn.addEventListener('click', close);
rs.closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', close);
// Calculate (advanced) -> preview only
rs.calcBtn.addEventListener('click', async () => {
if (rs.mode !== 'advanced') return;
try {
const { cols, rows } = sampleDims();
if (cols<=0 || rows<=0) { showToast('No samples. Adjust multiplier/offset.'); return; }
if (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM) { showToast(`Output too large. Must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}.`); return; }
const canvas = await reconstructViaGrid(rs.img, rs.origW, rs.origH, rs.offx, rs.offy, rs.gapX, rs.gapY);
rs.calcCanvas = canvas;
rs.calcCols = cols;
rs.calcRows = rows;
rs.calcReady = true;
rs.applyBtn.disabled = false;
drawAdvancedResultPreview();
updateFooterMeta();
showToast(`Calculated ${cols}×${rows}. Review preview, then Apply.`);
} catch (e) {
console.error(e);
showToast('Calculation failed.');
}
});
// Apply
rs.applyBtn.addEventListener('click', async () => {
if (!rs.ov) return;
try {
if (rs.mode === 'simple') {
const W = parseInt(rs.w.value||'0',10);
const H = parseInt(rs.h.value||'0',10);
if (!Number.isFinite(W) || !Number.isFinite(H) || W<=0 || H<=0) { showToast('Invalid dimensions'); return; }
if (W >= MAX_OVERLAY_DIM || H >= MAX_OVERLAY_DIM) { showToast(`Too large. Must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}.`); return; }
await resizeOverlayImage(rs.ov, W, H);
closeRSModal();
showToast(`Resized to ${W}×${H}.`);
} else {
if (!rs.calcReady || !rs.calcCanvas) { showToast('Calculate first.'); return; }
const dataUrl = await canvasToDataURLSafe(rs.calcCanvas);
rs.ov.imageBase64 = dataUrl;
rs.ov.imageUrl = null;
rs.ov.isLocal = true;
await saveConfig(['overlays']);
clearOverlayCache();
ensureHook();
updateUI();
closeRSModal();
showToast(`Applied ${rs.calcCols}×${rs.calcRows}.`);
}
} catch (e) {
console.error(e);
showToast('Apply failed.');
}
});
// expose for external callers
rs._syncAdvancedMeta = syncAdvancedMeta;
rs._syncSimpleNote = syncSimpleNote;
rs._setMode = (m) => {
const evt = new Event('click');
(m === 'simple' ? rs.tabSimple : rs.tabAdvanced).dispatchEvent(evt);
};
}
function openRSModal(overlay) {
if (!rs) return;
rs.ov = overlay;
const img = new Image();
img.onload = () => {
rs.img = img;
rs.origW = img.width; rs.origH = img.height;
// Initialize Simple
rs.orig.value = `${rs.origW}×${rs.origH}`;
rs.w.value = String(rs.origW);
rs.h.value = String(rs.origH);
rs.lock.checked = true;
// Initialize Advanced defaults
rs.zoom = 1.0;
rs.mult = 4;
rs.gapX = 4; rs.gapY = 4;
rs.offx = 0; rs.offy = 0;
rs.dotr = 1;
rs.viewX = 0; rs.viewY = 0;
rs.bind.checked = true;
rs.multRange.value = '4';
rs.multInput.value = '4';
rs.blockW.value = '4';
rs.blockH.value = '4';
rs.offX.value = '0';
rs.offY.value = '0';
rs.dotR.value = '1';
rs.dotRVal.textContent = '1';
rs.gridToggle.checked = true;
// Clear calculated result
rs.calcCanvas = null;
rs.calcCols = 0;
rs.calcRows = 0;
rs.calcReady = false;
rs.applyBtn.disabled = (rs.mode === 'advanced');
// Default to Simple tab
rs._setMode('simple');
document.body.classList.add('op-scroll-lock');
rs.backdrop.classList.add('show');
rs.modal.style.display = 'flex';
// Initial previews
rs._drawSimplePreview?.();
rs._drawAdvancedPreview?.();
rs._drawAdvancedResultPreview?.();
rs._syncAdvancedMeta?.();
rs._syncSimpleNote?.();
// Keep footer in sync with current tab
const setFooterNow = () => {
if (rs.mode === 'advanced') {
const { cols, rows } = (function(){ const x = Math.floor((rs.origW - rs.offx) / rs.gapX); const y = Math.floor((rs.origH - rs.offy) / rs.gapY); return { cols: Math.max(0,x), rows: Math.max(0,y) };})();
rs.meta.textContent = (cols>0&&rows>0) ? `Samples: ${cols} × ${rows} | Output: ${cols}×${rows}${(cols>=MAX_OVERLAY_DIM||rows>=MAX_OVERLAY_DIM)?` (exceeds limit: < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})`:''}` : 'Adjust multiplier/offset until dots sit at centers.';
} else {
const W = parseInt(rs.w.value||'0',10); const H = parseInt(rs.h.value||'0',10);
const ok = Number.isFinite(W)&&Number.isFinite(H)&&W>0&&H>0;
const limit = (W>=MAX_OVERLAY_DIM||H>=MAX_OVERLAY_DIM);
rs.meta.textContent = ok ? (limit ? `Target: ${W}×${H} (exceeds limit: must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})` : `Target: ${W}×${H} (OK)`) : 'Enter positive width and height.';
}
};
setFooterNow();
// Keep previews responsive while modal is open
const onResize = () => {
if (rs.mode === 'simple') rs._drawSimplePreview?.();
else {
rs._drawAdvancedPreview?.();
rs._drawAdvancedResultPreview?.();
}
};
rs._resizeHandler = onResize;
window.addEventListener('resize', onResize);
};
img.src = overlay.imageBase64;
}
function closeRSModal() {
if (!rs) return;
window.removeEventListener('resize', rs._resizeHandler || (()=>{}));
rs.backdrop.classList.remove('show');
rs.modal.style.display = 'none';
rs.ov = null;
rs.img = null;
document.body.classList.remove('op-scroll-lock');
}
// Simple nearest-neighbor resize with hardened alpha (HTML canvas to ensure toDataURL)
async function resizeOverlayImage(ov, targetW, targetH) {
const img = await loadImage(ov.imageBase64);
const canvas = createHTMLCanvas(targetW, targetH);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0,0,targetW,targetH);
ctx.drawImage(img, 0,0, img.width,img.height, 0,0, targetW,targetH);
// Harden alpha
const id = ctx.getImageData(0,0,targetW,targetH);
const data = id.data;
for (let i=0;i<data.length;i+=4) {
if (data[i+3] === 0) { data[i]=0; data[i+1]=0; data[i+2]=0; data[i+3]=0; }
else { data[i+3] = 255; }
}
ctx.putImageData(id, 0, 0);
const dataUrl = canvas.toDataURL('image/png');
ov.imageBase64 = dataUrl;
ov.imageUrl = null;
ov.isLocal = true;
await saveConfig(['overlays']);
clearOverlayCache();
ensureHook();
updateUI();
}
// Grid-center reconstruction (Pixel Reconstructor style) to HTML canvas
async function reconstructViaGrid(img, origW, origH, offx, offy, gapX, gapY) {
const srcCanvas = createCanvas(origW, origH);
const sctx = srcCanvas.getContext('2d', { willReadFrequently: true });
sctx.imageSmoothingEnabled = false;
sctx.drawImage(img, 0, 0);
const srcData = sctx.getImageData(0,0,origW,origH).data;
const cols = Math.floor((origW - offx) / gapX);
const rows = Math.floor((origH - offy) / gapY);
if (cols <= 0 || rows <= 0) throw new Error('No samples available with current offset/gap');
const outCanvas = createHTMLCanvas(cols, rows);
const octx = outCanvas.getContext('2d');
const out = octx.createImageData(cols, rows);
const odata = out.data;
const cx0 = offx + gapX / 2;
const cy0 = offy + gapY / 2;
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
for (let ry=0; ry<rows; ry++) {
for (let rx=0; rx<cols; rx++) {
const sx = Math.round(clamp(cx0 + rx*gapX, 0, origW-1));
const sy = Math.round(clamp(cy0 + ry*gapY, 0, origH-1));
const si = (sy*origW + sx) * 4;
const r = srcData[si];
const g = srcData[si+1];
const b = srcData[si+2];
const a = srcData[si+3];
const oi = (ry*cols + rx) * 4;
if (a === 0) {
odata[oi] = 0; odata[oi+1] = 0; odata[oi+2] = 0; odata[oi+3] = 0;
} else {
odata[oi] = r; odata[oi+1] = g; odata[oi+2] = b; odata[oi+3] = 255;
}
}
}
octx.putImageData(out, 0, 0);
return outCanvas;
}
function main() {
loadConfig().then(() => {
injectStyles();
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', createUI);
else createUI();
ensureHook();
applyTheme();
console.log("Overlay Pro: Initialized.");
});
}
main();
})();