This commit is contained in:
Decrypt 2025-09-02 11:06:19 +08:00 committed by GitHub
commit 83828b69a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1053 additions and 720 deletions

4
.gitignore vendored
View file

@ -127,4 +127,6 @@ yarn/cache
yarn/unplugged
yarn/build-state.yml
yarn/install-state.gz
pnp.*
pnp.*
.DS_Store
bun.lock

View file

@ -8,7 +8,7 @@ License: [`LICENSE.md`](LICENSE.md:1)
---
## Quick links
- Prebuilt userscript (install in Tampermonkey / Greasemonkey): [`dist/Wplace\ Overlay\ Pro.user.js`](dist/Wplace\ Overlay\ Pro.user.js:1)
- Prebuilt userscript (install in Tampermonkey / Greasemonkey): [Releases](https://github.com/Decryptu/Wplace-Overlay-Pro/releases)
- Build script (from source): see the `build` script in [`package.json`](package.json:7)
- Development watch script: see `watch` in [`package.json`](package.json:8)
- Important constants (limits): [`src/core/constants.ts`](src/core/constants.ts:1)
@ -40,8 +40,8 @@ License: [`LICENSE.md`](LICENSE.md:1)
## Installation
1. Install a userscript manager such as Tampermonkey or Violentmonkey in your browser.
2. Install the prebuilt userscript by opening the file produced by the build step and letting Tampermonkey import it:
3. Build from source:
2. Install the userscript in the [Releases](https://github.com/Decryptu/Wplace-Overlay-Pro/releases) page and letting Tampermonkey import it:
3. Or you can build from source:
- Install dependencies (npm or pnpm).
- Run the build script defined in [`package.json`](package.json:7).

View file

@ -12,7 +12,7 @@ const metaPath = resolve(__dirname, 'src', 'meta.js');
const MetaBannerPlugin = {
name: 'meta-banner',
setup(build) {
build.onEnd(async (result) => {
build.onEnd(async () => {
try {
await mkdir(dirname(outFile), { recursive: true });
const [meta, js] = await Promise.all([

View file

@ -6,6 +6,7 @@ import { createUI, updateUI } from './ui/panel';
import { displayImageFromData } from './core/overlay';
import { showToast } from './core/toast';
import { urlToDataURL } from './core/gm';
import { enablePaletteSymbols } from './core/palette-inject';
async function applyTemplateFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
@ -64,6 +65,7 @@ export async function bootstrapApp() {
createUI();
setUpdateUI(() => updateUI());
ensureHook();
enablePaletteSymbols();
await applyTemplateFromUrl();
console.log('Overlay Pro UI ready.');
}

View file

@ -4,8 +4,11 @@ export class LRUCache<K=any, V=any> {
constructor(max = 400) { this.max = max; this.map = new Map(); }
get(key: K): V | undefined {
if (!this.map.has(key)) return undefined;
const val = this.map.get(key)!;
this.map.delete(key); this.map.set(key, val);
const val = this.map.get(key);
if (val !== undefined) {
this.map.delete(key);
this.map.set(key, val);
}
return val;
}
set(key: K, val: V) {
@ -25,11 +28,13 @@ export const imageDecodeCache = new LRUCache<string, HTMLImageElement>(64);
export const paletteDetectionCache = new LRUCache<string, boolean>(200);
export const baseMinifyCache = new LRUCache<string, ImageData>(100);
export const tooLargeOverlays = new Set<string>();
export const overlayImageDataCache = new LRUCache<string, ImageData>(100);
export function clearOverlayCache() {
overlayCache.clear();
imageDecodeCache.clear();
paletteDetectionCache.clear();
baseMinifyCache.clear();
overlayImageDataCache.clear();
tooLargeOverlays.clear();
}

View file

@ -29,7 +29,8 @@ export async function canvasToDataURLSafe(canvas: OffscreenCanvas | HTMLCanvasEl
const bmp = (canvas as any).transferToImageBitmap?.();
if (bmp) {
const html = createHTMLCanvas(canvas.width, canvas.height);
const ctx = html.getContext('2d')!;
const ctx = html.getContext('2d');
if (!ctx) throw new Error('Failed to get 2D context');
ctx.drawImage(bmp, 0, 0);
return html.toDataURL('image/png');
}

View file

@ -4,6 +4,6 @@ export const EV_AUTOCAP_CHANGED = 'op-autocap-changed';
export function emit(name: string, detail?: any) {
try {
document.dispatchEvent(new CustomEvent(name, { detail }));
} catch (e) {
} catch {
}
}

View file

@ -32,6 +32,16 @@ export function attachHook() {
const hookedFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const urlStr = typeof input === 'string' ? input : ((input as Request).url) || '';
// EARLY FILTERING: Quick hostname check to skip non-wplace requests
if (!urlStr.includes('backend.wplace.live')) {
return originalFetch(input as any, init as any);
}
// EARLY FILTERING: Quick path check to skip non-tile/non-pixel requests
if (!urlStr.includes('/files/') && !urlStr.includes('/s0/pixel/')) {
return originalFetch(input as any, init as any);
}
// Anchor auto-capture: watch pixel endpoint, then store/normalize
if (config.autoCapturePixelUrl && config.activeOverlayId) {
const pixelMatch = matchPixelUrl(urlStr);

View file

@ -1,19 +1,26 @@
import { createCanvas, canvasToBlob, blobToImage, loadImage } from './canvas';
import { MINIFY_SCALE, MINIFY_SCALE_SYMBOL, TILE_SIZE, MAX_OVERLAY_DIM } from './constants';
import { imageDecodeCache, overlayCache, tooLargeOverlays, paletteDetectionCache, baseMinifyCache, clearOverlayCache } from './cache';
import { imageDecodeCache, overlayCache, tooLargeOverlays, paletteDetectionCache, baseMinifyCache, clearOverlayCache, overlayImageDataCache, LRUCache } from './cache';
import { showToast } from './toast';
import { config, saveConfig, type OverlayItem } from './store';
import { WPLACE_FREE, WPLACE_PAID, SYMBOL_TILES, SYMBOL_W, SYMBOL_H } from './palette';
import { getUpdateUI, ensureHook } from './hook';
// --- RLE Optimization: Types and Cache ---
type Run = { colorIndex: number; length: number };
type RLEData = Run[][];
const overlayRLECache = new LRUCache<string, RLEData>(50); // Cache for run-length encoded overlay data
const ALL_COLORS = [...WPLACE_FREE, ...WPLACE_PAID];
const colorIndexMap = new Map<string, number>();
ALL_COLORS.forEach((c, i) => colorIndexMap.set(c.join(','), i));
const LUT_SIZE = 32; // 32x32x32 = 32KB
const LUT_SHIFT = 8 - Math.log2(LUT_SIZE); // 3 for 32x32x32
const LUT_SIZE = 32;
const LUT_SHIFT = 8 - Math.log2(LUT_SIZE);
const colorLUT = new Uint8Array(LUT_SIZE * LUT_SIZE * LUT_SIZE);
const symbolCache = new Map<number, OffscreenCanvas | HTMLCanvasElement>();
// (buildColorLUT, findColorIndexLUT, findClosestColorIndex, getPreRenderedSymbol functions are unchanged)
function buildColorLUT() {
for (let r = 0; r < LUT_SIZE; r++) {
for (let g = 0; g < LUT_SIZE; g++) {
@ -27,25 +34,21 @@ function buildColorLUT() {
}
}
}
function findColorIndexLUT(r: number, g: number, b: number): number {
const lutR = r >> LUT_SHIFT;
const lutG = g >> LUT_SHIFT;
const lutB = b >> LUT_SHIFT;
return colorLUT[lutR * LUT_SIZE * LUT_SIZE + lutG * LUT_SIZE + lutB];
}
buildColorLUT();
function findClosestColorIndex(r: number, g: number, b: number) {
let minDistance = Infinity;
let index = 0;
for (let i = 0; i < ALL_COLORS.length; i++) {
const color = ALL_COLORS[i];
const distance = Math.sqrt(
Math.pow(r - color[0], 2) +
Math.pow(g - color[1], 2) +
Math.pow(b - color[2], 2)
(r - color[0]) ** 2 +
(g - color[1]) ** 2 +
(b - color[2]) ** 2
);
if (distance < minDistance) {
minDistance = distance;
@ -54,6 +57,67 @@ function findClosestColorIndex(r: number, g: number, b: number) {
}
return index;
}
function getPreRenderedSymbol(colorIndex: number): OffscreenCanvas | HTMLCanvasElement | null {
const cachedSymbol = symbolCache.get(colorIndex);
if (cachedSymbol) {
return cachedSymbol;
}
if (colorIndex >= SYMBOL_TILES.length) return null;
const scale = MINIFY_SCALE_SYMBOL;
const canvas = createCanvas(scale, scale);
const ctx = canvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
if (!ctx) return null;
const symbol = SYMBOL_TILES[colorIndex];
const paletteColor = ALL_COLORS[colorIndex];
ctx.fillStyle = `rgb(${paletteColor[0]}, ${paletteColor[1]}, ${paletteColor[2]})`;
const centerX = (scale - SYMBOL_W) >> 1;
const centerY = (scale - SYMBOL_H) >> 1;
for (let sy = 0; sy < SYMBOL_H; sy++) {
for (let sx = 0; sx < SYMBOL_W; sx++) {
const bit_idx = sy * SYMBOL_W + sx;
if ((symbol >>> bit_idx) & 1) {
ctx.fillRect(sx + centerX, sy + centerY, 1, 1);
}
}
}
symbolCache.set(colorIndex, canvas);
return canvas;
}
buildColorLUT();
// --- RLE Optimization: Function to generate RLE data from ImageData ---
function generateRLEData(imageData: ImageData, isPalettePerfect: boolean): RLEData {
const { width, height, data } = imageData;
const rleData: RLEData = [];
for (let y = 0; y < height; y++) {
const row: Run[] = [];
let currentRun: Run | null = null;
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
// Treat transparent or #deface as "no color" (index -1)
const colorIndex = (a <= 128 || (r === 0xde && g === 0xfa && b === 0xce))
? -1
: (isPalettePerfect ? (colorIndexMap.get(`${r},${g},${b}`) ?? -1) : findColorIndexLUT(r, g, b));
if (currentRun && currentRun.colorIndex === colorIndex) {
currentRun.length++;
} else {
if (currentRun) row.push(currentRun);
currentRun = { colorIndex, length: 1 };
}
}
if (currentRun) row.push(currentRun);
rleData.push(row);
}
return rleData;
}
export function extractPixelCoords(pixelUrl: string) {
try {
@ -70,7 +134,6 @@ export function extractPixelCoords(pixelUrl: string) {
return { chunk1: 0, chunk2: 0, posX: 0, posY: 0 };
}
}
export function matchTileUrl(urlStr: string) {
try {
const u = new URL(urlStr, location.href);
@ -80,7 +143,6 @@ export function matchTileUrl(urlStr: string) {
return { chunk1: parseInt(m[1], 10), chunk2: parseInt(m[2], 10) };
} catch { return null; }
}
export function matchPixelUrl(urlStr: string) {
try {
const u = new URL(urlStr, location.href);
@ -91,44 +153,35 @@ export function matchPixelUrl(urlStr: string) {
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; }
}
export function rectIntersect(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number) {
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 };
}
function isPalettePerfectImage(img: HTMLImageElement): boolean {
const key = img.src;
const cached = paletteDetectionCache.get(key);
if (cached !== undefined) return cached;
const canvas = createCanvas(img.width, img.height) as any;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
const ctx = canvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
if (!ctx) throw new Error('Failed to get 2D context');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
if (a === 0) continue;
// Skip #deface transparency
if (r === 0xde && g === 0xfa && b === 0xce) continue;
const colorKey = `${r},${g},${b}`;
if (!colorIndexMap.has(colorKey)) {
paletteDetectionCache.set(key, false);
return false;
}
}
paletteDetectionCache.set(key, true);
return true;
}
export async function decodeOverlayImage(imageBase64: string | null) {
if (!imageBase64) return null;
const key = imageBase64;
@ -138,7 +191,6 @@ export async function decodeOverlayImage(imageBase64: string | null) {
imageDecodeCache.set(key, img);
return img;
}
export function overlaySignature(ov: {
imageBase64: string | null,
pixelUrl: string | null,
@ -146,10 +198,23 @@ export function overlaySignature(ov: {
offsetY: number,
opacity: number,
}, isPalettePerfect?: boolean) {
const imgKey = ov.imageBase64 ? ov.imageBase64.slice(0, 64) + ':' + ov.imageBase64.length : 'none';
const imgKey = ov.imageBase64 ? `${ov.imageBase64.slice(0, 64)}:${ov.imageBase64.length}` : 'none';
const perfectFlag = isPalettePerfect !== undefined ? (isPalettePerfect ? 'P' : 'I') : 'U';
return [imgKey, ov.pixelUrl || 'null', ov.offsetX, ov.offsetY, ov.opacity, perfectFlag].join('|');
}
async function getCachedOverlayImageData(img: HTMLImageElement, overlayId: string): Promise<ImageData> {
const cacheKey = `${overlayId}:${img.width}x${img.height}`;
const cached = overlayImageDataCache.get(cacheKey);
if (cached) return cached;
const canvas = createCanvas(img.width, img.height) as any;
const ctx = canvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
if (!ctx) throw new Error('Failed to get 2D context');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
overlayImageDataCache.set(cacheKey, imageData);
return imageData;
}
export async function buildOverlayDataForChunkUnified(
ov: {
@ -180,185 +245,113 @@ export async function buildOverlayDataForChunkUnified(
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);
// Check if image is palette-perfect for optimization
const isPalettePerfect = isPalettePerfectImage(img);
const sig = overlaySignature(ov, isPalettePerfect);
const cacheKey = `ov:${ov.id}|sig:${sig}|tile:${targetChunk1},${targetChunk2}|mode:${mode}`;
const cacheKey = `ov:${ov.id}|sig:${sig}|tile:${targetChunk1},${targetChunk2}|mode:${mode}:${config.minifyStyle}`;
const cached = overlayCache.get(cacheKey);
if (cached !== undefined) return cached;
const colorStrength = (mode === 'minify') ? 1.0 : ov.opacity;
const whiteStrength = 1 - colorStrength;
if (mode !== 'minify') {
// This part for 'behind' and 'above' modes is unchanged
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) as any;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
ctx.drawImage(img as any, drawX, drawY);
const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
// Special case for #deface color
if (r === 0xde && g === 0xfa && b === 0xce) {
continue;
}
if (a > 0) {
data[i] = Math.round(r * colorStrength + 255 * whiteStrength);
data[i + 1] = Math.round(g * colorStrength + 255 * whiteStrength);
data[i + 2] = Math.round(b * colorStrength + 255 * whiteStrength);
data[i + 3] = 255;
}
}
const canvas = createCanvas(isect.w, isect.h) as any;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
if (!ctx) throw new Error('Failed to get 2D context');
ctx.drawImage(img as any, isect.x - drawX, isect.y - drawY, isect.w, isect.h, 0, 0, isect.w, isect.h);
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = `rgba(255, 255, 255, ${1 - ov.opacity})`;
ctx.fillRect(0, 0, isect.w, isect.h);
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = `rgba(255, 255, 255, 1)`;
ctx.fillRect(0, 0, isect.w, isect.h);
const imageData = ctx.getImageData(0, 0, isect.w, isect.h);
const result = { imageData, dx: isect.x, dy: isect.y, scaled: false };
overlayCache.set(cacheKey, result);
return result;
} else {
if (config.minifyStyle === 'symbols') {
const scale = MINIFY_SCALE_SYMBOL;
const tileW = TILE_SIZE * scale;
const tileH = TILE_SIZE * scale;
const drawXScaled = Math.round(drawX * scale);
const drawYScaled = Math.round(drawY * scale);
const wScaled = wImg * scale;
const hScaled = hImg * scale;
const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled);
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
} else { // --- RLE Optimized Minify Rendering ---
const scale = config.minifyStyle === 'symbols' ? MINIFY_SCALE_SYMBOL : MINIFY_SCALE;
const tileScaledW = TILE_SIZE * scale;
const tileScaledH = TILE_SIZE * scale;
const drawXScaled = Math.round(drawX * scale);
const drawYScaled = Math.round(drawY * scale);
const wImgScaled = wImg * scale;
const hImgScaled = hImg * scale;
const canvas = createCanvas(wImg, hImg) as any;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img as any, 0, 0);
const originalImageData = ctx.getImageData(0, 0, wImg, hImg);
const isect = rectIntersect(0, 0, tileScaledW, tileScaledH, drawXScaled, drawYScaled, wImgScaled, hImgScaled);
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
const outCanvas = createCanvas(tileW, tileH) as any;
const outCtx = outCanvas.getContext('2d', { willReadFrequently: true })!;
const outputImageData = outCtx.createImageData(tileW, tileH);
const outData = outputImageData.data;
// Precompute symbol centering offsets for performance
const centerX = (scale - SYMBOL_W) >> 1;
const centerY = (scale - SYMBOL_H) >> 1;
for (let y = 0; y < TILE_SIZE; y++) {
for (let x = 0; x < TILE_SIZE; x++) {
const imgX = x - drawX;
const imgY = y - drawY;
if (imgX >= 0 && imgX < wImg && imgY >= 0 && imgY < hImg) {
const idx = (imgY * wImg + imgX) * 4;
const r = originalImageData.data[idx];
const g = originalImageData.data[idx+1];
const b = originalImageData.data[idx+2];
const a = originalImageData.data[idx+3];
// Early exit for transparent or deface pixels
if (a <= 128 || (r === 0xde && g === 0xfa && b === 0xce)) continue;
let colorIndex: number;
// Fast path for palette-perfect images
if (isPalettePerfect) {
const colorKey = `${r},${g},${b}`;
colorIndex = colorIndexMap.get(colorKey) ?? 0;
} else {
// Use LUT for fast color matching
colorIndex = findColorIndexLUT(r, g, b);
}
if (colorIndex < SYMBOL_TILES.length) {
const symbol = SYMBOL_TILES[colorIndex];
const tileX = x * scale;
const tileY = y * scale;
// Cache palette color to avoid repeated array access
const paletteColor = ALL_COLORS[colorIndex];
const a_r = paletteColor[0];
const a_g = paletteColor[1];
const a_b = paletteColor[2];
for (let sy = 0; sy < SYMBOL_H; sy++) {
for (let sx = 0; sx < SYMBOL_W; sx++) {
const bit_idx = sy * SYMBOL_W + sx;
const bit = (symbol >>> bit_idx) & 1;
if (bit) {
const outX = tileX + sx + centerX;
const outY = tileY + sy + centerY;
if (outX >= 0 && outX < tileW && outY >= 0 && outY < tileH) {
const outIdx = (outY * tileW + outX) * 4;
outData[outIdx] = a_r;
outData[outIdx+1] = a_g;
outData[outIdx+2] = a_b;
outData[outIdx+3] = 255;
}
}
}
}
}
}
}
}
outCtx.putImageData(outputImageData, 0, 0);
const finalIsect = rectIntersect(0, 0, tileW, tileH, 0, 0, tileW, tileH);
const finalImageData = outCtx.getImageData(finalIsect.x, finalIsect.y, finalIsect.w, finalIsect.h);
const result = { imageData: finalImageData, dx: finalIsect.x, dy: finalIsect.y, scaled: true, scale };
overlayCache.set(cacheKey, result);
return result;
} else { // 'dots'
const scale = MINIFY_SCALE;
const tileW = TILE_SIZE * scale;
const tileH = TILE_SIZE * scale;
const drawXScaled = Math.round(drawX * scale);
const drawYScaled = Math.round(drawY * scale);
const wScaled = wImg * scale;
const hScaled = hImg * scale;
const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled);
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
const canvas = createCanvas(tileW, tileH) as any;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, tileW, tileH);
ctx.drawImage(img as any, 0, 0, wImg, hImg, drawXScaled, drawYScaled, wScaled, hScaled);
const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h);
const data = imageData.data;
const center = Math.floor(scale / 2);
const width = isect.w;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a === 0) continue;
const px = (i / 4) % width;
const py = Math.floor((i / 4) / width);
const absX = isect.x + px;
const absY = isect.y + py;
if ((absX % scale) === center && (absY % scale) === center) {
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;
} else {
data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 0;
}
}
const result = { imageData, dx: isect.x, dy: isect.y, scaled: true, scale };
overlayCache.set(cacheKey, result);
return result;
// Get or generate RLE data for the overlay
const rleCacheKey = `${ov.id}:${sig}`;
let rleData = overlayRLECache.get(rleCacheKey);
if (!rleData) {
const originalImageData = await getCachedOverlayImageData(img, ov.id);
rleData = generateRLEData(originalImageData, isPalettePerfect);
overlayRLECache.set(rleCacheKey, rleData);
}
const outCanvas = createCanvas(isect.w, isect.h) as any;
const outCtx = outCanvas.getContext('2d') as CanvasRenderingContext2D;
if (!outCtx) throw new Error('Failed to get 2D context');
// Calculate the range of source image rows to iterate over
const startY = Math.max(0, Math.floor((isect.y - drawYScaled) / scale));
const endY = Math.min(hImg, Math.ceil((isect.y + isect.h - drawYScaled) / scale));
for (let y = startY; y < endY; y++) {
const rowRLE = rleData[y];
let currentX = 0;
const outY = drawYScaled + y * scale - isect.y;
for (const run of rowRLE) {
if (run.colorIndex !== -1) {
const runStartImgX = currentX;
const runEndImgX = currentX + run.length;
// Find intersection of this run with the visible tile area
const visibleStartImgX = Math.max(runStartImgX, Math.floor((isect.x - drawXScaled) / scale));
const visibleEndImgX = Math.min(runEndImgX, Math.ceil((isect.x + isect.w - drawXScaled) / scale));
if (visibleEndImgX > visibleStartImgX) {
const visibleLength = visibleEndImgX - visibleStartImgX;
const outX = drawXScaled + visibleStartImgX * scale - isect.x;
if (config.minifyStyle === 'symbols') {
const symbolCanvas = getPreRenderedSymbol(run.colorIndex);
if (symbolCanvas) {
const pattern = outCtx.createPattern(symbolCanvas, 'repeat');
if (pattern) {
outCtx.fillStyle = pattern;
// We need to translate the context so the pattern aligns with the grid
outCtx.save();
outCtx.translate(outX, outY);
outCtx.fillRect(0, 0, visibleLength * scale, scale);
outCtx.restore();
}
}
} else { // 'dots'
const color = ALL_COLORS[run.colorIndex];
outCtx.fillStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
const center = Math.floor(scale / 2);
for (let i = 0; i < visibleLength; i++) {
outCtx.fillRect(outX + i * scale + center, outY + center, 1, 1);
}
}
}
}
currentX += run.length;
}
}
const imageData = outCtx.getImageData(0, 0, isect.w, isect.h);
const result = { imageData, dx: isect.x, dy: isect.y, scaled: true, scale };
overlayCache.set(cacheKey, result);
return result;
}
}
// composeTileUnified is unchanged
export async function composeTileUnified(
originalBlob: Blob,
overlayDatas: Array<{ imageData: ImageData, dx: number, dy: number, scaled?: boolean } | null>,
@ -366,69 +359,59 @@ export async function composeTileUnified(
) {
if (!overlayDatas || overlayDatas.length === 0) return originalBlob;
const originalImage = await blobToImage(originalBlob) as any;
if (mode === 'minify') {
const scale = config.minifyStyle === 'symbols' ? MINIFY_SCALE_SYMBOL : MINIFY_SCALE;
const w = originalImage.width, h = originalImage.height;
const arrayBuffer = await originalBlob.arrayBuffer();
const view = new DataView(arrayBuffer);
const hash = view.getUint32(0, true) ^ view.getUint32(view.byteLength - 4, true);
const baseCacheKey = `base:${originalBlob.size}:${hash}:${w}x${h}:${scale}:${config.minifyStyle}`;
let scaledBaseImageData = baseMinifyCache.get(baseCacheKey);
if (!scaledBaseImageData) {
const baseCanvas = createCanvas(w * scale, h * scale) as any;
const baseCtx = baseCanvas.getContext('2d', { willReadFrequently: true })!;
const baseCtx = baseCanvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
if (!baseCtx) throw new Error('Failed to get 2D context');
baseCtx.imageSmoothingEnabled = false;
baseCtx.drawImage(originalImage, 0, 0, w * scale, h * scale);
scaledBaseImageData = baseCtx.getImageData(0, 0, w * scale, h * scale);
baseMinifyCache.set(baseCacheKey, scaledBaseImageData);
}
const canvas = createCanvas(w * scale, h * scale) as any;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
const ctx = canvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D;
if (!ctx) throw new Error('Failed to get 2D context');
ctx.putImageData(scaledBaseImageData, 0, 0);
for (const ovd of overlayDatas) {
if (!ovd) continue;
const tw = ovd.imageData.width;
const th = ovd.imageData.height;
if (!tw || !th) continue;
const temp = createCanvas(tw, th) as any;
const tctx = temp.getContext('2d', { willReadFrequently: true })!;
const temp = createCanvas(ovd.imageData.width, ovd.imageData.height) as any;
const tctx = temp.getContext('2d') as CanvasRenderingContext2D;
if (!tctx) continue;
tctx.putImageData(ovd.imageData, 0, 0);
ctx.drawImage(temp, ovd.dx, ovd.dy);
}
return await canvasToBlob(canvas);
}
const w = originalImage.width, h = originalImage.height;
const canvas = createCanvas(w, h) as any;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
if (!ctx) throw new Error('Failed to get 2D context');
const drawOverlays = () => {
for (const ovd of overlayDatas) {
if (!ovd) continue;
const temp = createCanvas(ovd.imageData.width, ovd.imageData.height) as any;
const tctx = temp.getContext('2d') as CanvasRenderingContext2D;
if (!tctx) continue;
tctx.putImageData(ovd.imageData, 0, 0);
ctx.drawImage(temp, ovd.dx, ovd.dy);
}
};
if (mode === 'behind') {
for (const ovd of overlayDatas) {
if (!ovd) continue;
const temp = createCanvas(ovd.imageData.width, ovd.imageData.height) as any;
const tctx = temp.getContext('2d', { willReadFrequently: true })!;
tctx.putImageData(ovd.imageData, 0, 0);
ctx.drawImage(temp, ovd.dx, ovd.dy);
}
drawOverlays();
ctx.drawImage(originalImage, 0, 0);
return await canvasToBlob(canvas);
} else {
} else { // 'above'
ctx.drawImage(originalImage, 0, 0);
for (const ovd of overlayDatas) {
if (!ovd) continue;
const temp = createCanvas(ovd.imageData.width, ovd.imageData.height) as any;
const tctx = temp.getContext('2d', { willReadFrequently: true })!;
tctx.putImageData(ovd.imageData, 0, 0);
ctx.drawImage(temp, ovd.dx, ovd.dy);
}
return await canvasToBlob(canvas);
drawOverlays();
}
return await canvasToBlob(canvas);
}
export async function displayImageFromData(newOverlay: OverlayItem) {
@ -439,6 +422,8 @@ export async function displayImageFromData(newOverlay: OverlayItem) {
await saveConfig();
clearOverlayCache();
overlayRLECache.clear();
symbolCache.clear();
ensureHook();
const updateUI = getUpdateUI();

195
src/core/palette-inject.ts Normal file
View file

@ -0,0 +1,195 @@
import { WPLACE_FREE, WPLACE_PAID, SYMBOL_TILES, SYMBOL_W, SYMBOL_H } from './palette';
import { config } from './store';
const ALL_COLORS = [...WPLACE_FREE, ...WPLACE_PAID];
let paletteObserver: MutationObserver | null = null;
let isInjected = false;
function createSymbolCanvas(colorIndex: number, _bgColor: string): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 20;
canvas.height = 20;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get 2D context');
// Clear with transparency
ctx.clearRect(0, 0, 20, 20);
if (colorIndex < SYMBOL_TILES.length) {
const symbol = SYMBOL_TILES[colorIndex];
const scale = 3;
const offsetX = Math.floor((20 - SYMBOL_W * scale) / 2);
const offsetY = Math.floor((20 - SYMBOL_H * scale) / 2);
// Draw white outline first (slightly larger)
ctx.fillStyle = '#ffffff';
for (let y = 0; y < SYMBOL_H; y++) {
for (let x = 0; x < SYMBOL_W; x++) {
const bitIndex = y * SYMBOL_W + x;
const bit = (symbol >>> bitIndex) & 1;
if (bit) {
ctx.fillRect(
offsetX + x * scale - 1,
offsetY + y * scale - 1,
scale + 2,
scale + 2
);
}
}
}
// Draw black symbol on top
ctx.fillStyle = '#000000';
for (let y = 0; y < SYMBOL_H; y++) {
for (let x = 0; x < SYMBOL_W; x++) {
const bitIndex = y * SYMBOL_W + x;
const bit = (symbol >>> bitIndex) & 1;
if (bit) {
ctx.fillRect(
offsetX + x * scale,
offsetY + y * scale,
scale,
scale
);
}
}
}
}
return canvas;
}
function getColorFromButton(button: HTMLElement): string | null {
const style = button.getAttribute('style');
if (!style) return null;
const match = style.match(/background:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!match) return null;
return `${match[1]},${match[2]},${match[3]}`;
}
function findColorIndex(colorKey: string): number {
return ALL_COLORS.findIndex(([r, g, b]) => `${r},${g},${b}` === colorKey);
}
function injectSymbolsIntoPalette() {
if (isInjected) return;
// Find all color buttons in the palette
const colorButtons = document.querySelectorAll('button[id^="color-"]');
if (colorButtons.length === 0) return;
colorButtons.forEach((button) => {
const htmlButton = button as HTMLElement;
const colorKey = getColorFromButton(htmlButton);
if (!colorKey) return;
const colorIndex = findColorIndex(colorKey);
if (colorIndex === -1) return;
// Check if symbol already exists
if (htmlButton.querySelector('.symbol-overlay')) return;
// Create symbol overlay
const symbolCanvas = createSymbolCanvas(colorIndex, colorKey);
symbolCanvas.className = 'symbol-overlay';
symbolCanvas.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 10;
opacity: 0.9;
`;
// Make button position relative if it isn't already
const computedStyle = window.getComputedStyle(htmlButton);
if (computedStyle.position === 'static') {
htmlButton.style.position = 'relative';
}
htmlButton.appendChild(symbolCanvas);
});
isInjected = true;
}
function cleanupSymbols() {
const symbols = document.querySelectorAll('.symbol-overlay');
symbols.forEach(symbol => symbol.remove());
isInjected = false;
}
function startPaletteWatcher() {
if (paletteObserver) return;
paletteObserver = new MutationObserver((mutations) => {
let shouldCheck = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check if palette was added or removed
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (element.querySelector('button[id^="color-"]') ||
element.matches('button[id^="color-"]')) {
shouldCheck = true;
}
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (element.querySelector('.symbol-overlay') ||
element.matches('.symbol-overlay')) {
isInjected = false;
}
}
});
}
});
if (shouldCheck && config.overlayMode === 'minify' && config.minifyStyle === 'symbols') {
// Small delay to ensure DOM is fully updated
setTimeout(injectSymbolsIntoPalette, 100);
}
});
paletteObserver.observe(document.body, {
childList: true,
subtree: true
});
}
export function enablePaletteSymbols() {
startPaletteWatcher();
// Try to inject immediately if palette is already open
if (config.overlayMode === 'minify' && config.minifyStyle === 'symbols') {
setTimeout(injectSymbolsIntoPalette, 500);
}
}
export function disablePaletteSymbols() {
if (paletteObserver) {
paletteObserver.disconnect();
paletteObserver = null;
}
cleanupSymbols();
}
export function updatePaletteSymbols() {
cleanupSymbols();
if (config.overlayMode === 'minify' && config.minifyStyle === 'symbols') {
setTimeout(injectSymbolsIntoPalette, 100);
}
}

View file

@ -3,13 +3,11 @@
import { bootstrapApp } from "./app";
(function () {
"use strict";
(() => {
window.onload = () => {
bootstrapApp().catch((e) =>
console.error("Overlay Pro bootstrap failed", e),
);
};
})();
export {};

View file

@ -1,7 +1,7 @@
// ==UserScript==
// @name Wplace Overlay Pro
// @namespace http://tampermonkey.net/
// @version 3.1.5
// @version 3.1.6
// @description Overlays tiles on wplace.live. Can also resize, and color-match your overlay to wplace's palette. Make sure to comply with the site's Terms of Service, and rules! This script is not affiliated with Wplace.live in any way, use at your own risk. This script is not affiliated with TamperMonkey. The author of this userscript is not responsible for any damages, issues, loss of data, or punishment that may occur as a result of using this script. This script is provided "as is" under GPLv3.
// @author shinkonet
// @match https://wplace.live/*

View file

@ -1,6 +1,5 @@
/// <reference types="tampermonkey" />
import { WPLACE_FREE, WPLACE_PAID, WPLACE_NAMES, DEFAULT_FREE_KEYS } from '../core/palette';
import { createCanvas } from '../core/canvas';
import { config, saveConfig } from '../core/store';
import { MAX_OVERLAY_DIM } from '../core/constants';
import { ensureHook } from '../core/hook';
@ -107,7 +106,8 @@ export function buildCCModal() {
document.body.appendChild(modal);
const previewCanvas = modal.querySelector('#op-cc-preview') as HTMLCanvasElement;
const previewCtx = previewCanvas.getContext('2d', { willReadFrequently: true })!;
const previewCtx = previewCanvas.getContext('2d', { willReadFrequently: true });
if (!previewCtx) return;
cc = {
backdrop,
@ -142,49 +142,55 @@ export function buildCCModal() {
isStale: false
};
modal.querySelector('#op-cc-close')!.addEventListener('click', closeCCModal);
const closeBtn = modal.querySelector('#op-cc-close') as HTMLButtonElement | null;
closeBtn?.addEventListener('click', closeCCModal);
backdrop.addEventListener('click', closeCCModal);
modal.querySelector('#op-cc-cancel')!.addEventListener('click', closeCCModal);
const cancelBtn = modal.querySelector('#op-cc-cancel') as HTMLButtonElement | null;
cancelBtn?.addEventListener('click', closeCCModal);
const zoomIn = async () => {
cc!.zoom = Math.min(8, (cc!.zoom || 1) * 1.25);
config.ccZoom = cc!.zoom; await saveConfig(['ccZoom']);
if (!cc) return;
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']);
if (!cc) return;
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);
(modal.querySelector('#op-cc-zoom-in') as HTMLButtonElement | null)?.addEventListener('click', zoomIn);
(modal.querySelector('#op-cc-zoom-out') as HTMLButtonElement | null)?.addEventListener('click', zoomOut);
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();
cc.realtimeBtn?.addEventListener('click', async () => {
if (!cc) return;
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();
});
cc.recalcBtn.addEventListener('click', () => { recalcNow(); });
cc.recalcBtn?.addEventListener('click', () => { recalcNow(); });
cc.applyBtn.addEventListener('click', async () => {
const ov = cc!.overlay; if (!ov || !cc!.processedCanvas) return;
if (cc!.processedCanvas.width >= MAX_OVERLAY_DIM || cc!.processedCanvas.height >= MAX_OVERLAY_DIM) {
cc.applyBtn?.addEventListener('click', async () => {
if (!cc) return;
const ov = cc.overlay; if (!ov || !cc.processedCanvas) 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');
const dataUrl = cc.processedCanvas.toDataURL('image/png');
ov.imageBase64 = dataUrl; ov.imageUrl = null; ov.isLocal = true;
// Mark the processed image as palette-perfect for optimization
paletteDetectionCache.set(dataUrl, true);
await saveConfig(['overlays']); clearOverlayCache(); ensureHook();
emitOverlayChanged();
const uniqueColors = Object.keys(cc!.lastColorCounts).length;
showToast(`Overlay updated (${cc!.processedCanvas.width}×${cc!.processedCanvas.height}, ${uniqueColors} colors).`);
const uniqueColors = Object.keys(cc.lastColorCounts).length;
showToast(`Overlay updated (${cc.processedCanvas.width}×${cc.processedCanvas.height}, ${uniqueColors} colors).`);
closeCCModal();
});
@ -204,22 +210,31 @@ export function openCCModal(overlay: any) {
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);
if (!cc) return;
cc!.sourceImageData = cc!.sourceCtx!.getImageData(0,0,img.width,img.height);
if (!cc.sourceCanvas) {
cc.sourceCanvas = document.createElement('canvas');
cc.sourceCtx = cc.sourceCanvas.getContext('2d', { willReadFrequently: true }) || null;
}
if (!cc.sourceCanvas) return;
if (!cc!.processedCanvas) { cc!.processedCanvas = document.createElement('canvas'); cc!.processedCtx = cc!.processedCanvas.getContext('2d')!; }
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 ? cc.sourceCtx.getImageData(0, 0, img.width, img.height) : null;
if (!cc.processedCanvas) {
cc.processedCanvas = document.createElement('canvas');
cc.processedCtx = cc.processedCanvas.getContext('2d') || null;
}
processImage();
cc!.isStale = false;
cc.isStale = false;
applyPreview();
updateMeta();
cc!.backdrop.classList.add('show');
cc!.modal.style.display = 'flex';
cc.backdrop.classList.add('show');
cc.modal.style.display = 'flex';
};
img.src = overlay.imageBase64;
}
@ -240,20 +255,20 @@ function weightedNearest(r: number, g: number, b: number, palette: number[][]) {
const rdiff = pr - r;
const gdiff = pg - g;
const bdiff = pb - b;
const x = (512 + rmean) * rdiff * rdiff >> 8;
const x = ((512 + rmean) * rdiff * rdiff) >> 8;
const y = 4 * gdiff * gdiff;
const z = (767 - rmean) * bdiff * bdiff >> 8;
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];
return best || [0, 0, 0];
}
function getActivePalette(): number[][] {
if (!cc) return [];
const arr: number[][] = [];
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]); });
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;
}
@ -268,19 +283,24 @@ function processImage() {
const counts: Record<string, number> = {};
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 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')!; }
if (!cc.processedCanvas) {
cc.processedCanvas = document.createElement('canvas');
cc.processedCtx = cc.processedCanvas.getContext('2d') || null;
}
if (!cc.processedCanvas) return;
cc.processedCanvas.width = w; cc.processedCanvas.height = h;
const outImg = new ImageData(out, w, h);
cc.processedCtx!.putImageData(outImg, 0, 0);
cc.processedCtx?.putImageData(outImg, 0, 0);
cc.lastColorCounts = counts;
}
@ -296,16 +316,16 @@ function applyPreview() {
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;
ctx.clearRect(0, 0, pw, ph);
(ctx as any).imageSmoothingEnabled = false;
ctx.drawImage(srcCanvas, 0, 0, srcCanvas.width, srcCanvas.height, 0, 0, pw, ph);
(ctx as any).imageSmoothingEnabled = true;
}
function updateMeta() {
if (!cc || !cc.sourceImageData) { if (cc) cc.meta.textContent = ''; return; }
const w = cc.sourceImageData.width, h = cc.sourceImageData.height;
const colorsUsed = Object.keys(cc.lastColorCounts||{}).length;
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}`;
}
@ -315,92 +335,105 @@ function renderPaletteGrid() {
cc.freeGrid.innerHTML = '';
cc.paidGrid.innerHTML = '';
for (const [r,g,b] of WPLACE_FREE) {
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';
(cell as any).dataset.key = key;
(cell as any).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; }
if (!cc) return;
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) {
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';
(cell as any).dataset.key = key;
(cell as any).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; }
if (!cc) return;
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);
}
cc.freeToggle.addEventListener('click', async () => {
if (!cc) return;
const allActive = isAllFreeActive();
setAllActive('free', !allActive);
config.ccFreeKeys = Array.from(cc!.selectedFree);
config.ccFreeKeys = Array.from(cc.selectedFree);
await saveConfig(['ccFreeKeys']);
if (cc!.realtime) recalcNow(); else markStale();
if (cc.realtime) recalcNow(); else markStale();
applyPreview(); updateMeta(); updateMasterButtons();
});
cc.paidToggle.addEventListener('click', async () => {
if (!cc) return;
const allActive = isAllPaidActive();
setAllActive('paid', !allActive);
config.ccPaidKeys = Array.from(cc!.selectedPaid);
config.ccPaidKeys = Array.from(cc.selectedPaid);
await saveConfig(['ccPaidKeys']);
if (cc!.realtime) recalcNow(); else markStale();
if (cc.realtime) recalcNow(); else markStale();
applyPreview(); updateMeta(); updateMasterButtons();
});
updateMasterButtons();
}
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 isAllFreeActive(): boolean {
if (!cc) return false;
return DEFAULT_FREE_KEYS.every(k => cc.selectedFree.has(k));
}
function setAllActive(type: 'free'|'paid', active: boolean) {
function isAllPaidActive(): boolean {
if (!cc) return false;
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: 'free' | 'paid', active: boolean) {
if (!cc) return;
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));
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));
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));
}
}
function updateMasterButtons() {
cc!.freeToggle.textContent = isAllFreeActive() ? 'Unselect All' : 'Select All';
cc!.paidToggle.textContent = isAllPaidActive() ? 'Unselect All' : 'Select All';
if (!cc) return;
cc.freeToggle.textContent = isAllFreeActive() ? 'Unselect All' : 'Select All';
cc.paidToggle.textContent = isAllPaidActive() ? 'Unselect All' : 'Select All';
}
function recalcNow() {
if (!cc) return;
processImage();
cc!.isStale = false;
cc.isStale = false;
applyPreview();
updateMeta();
}
function markStale() {
cc!.isStale = true;
cc!.meta.textContent = cc!.meta.textContent.replace(/ \| Status: .+$/, '') + ' | Status: pending recalculation';
if (!cc) return;
cc.isStale = true;
const base = (cc.meta.textContent || '').replace(/ \| Status: .+$/, '');
cc.meta.textContent = `${base} | Status: pending recalculation`;
}

View file

@ -9,10 +9,11 @@ import { extractPixelCoords } from '../core/overlay';
import { buildCCModal, openCCModal } from './ccModal';
import { buildRSModal, openRSModal } from './rsModal';
import { EV_ANCHOR_SET, EV_AUTOCAP_CHANGED } from '../core/events';
import { updatePaletteSymbols } from '../core/palette-inject';
let panelEl: HTMLDivElement | null = null;
function $(id: string) { return document.getElementById(id)!; }
function $(id: string): HTMLElement | null { return document.getElementById(id); }
export function createUI() {
if (document.getElementById('overlay-pro-panel')) return;
@ -23,8 +24,10 @@ export function createUI() {
const panelW = 340;
const defaultLeft = Math.max(12, window.innerWidth - panelW - 80);
panel.style.left = (Number.isFinite(config.panelX as any) ? (config.panelX as any) : defaultLeft) + 'px';
panel.style.top = (Number.isFinite(config.panelY as any) ? (config.panelY as any) : 120) + 'px';
const initLeft = (typeof config.panelX === 'number' && Number.isFinite(config.panelX)) ? config.panelX : defaultLeft;
const initTop = (typeof config.panelY === 'number' && Number.isFinite(config.panelY)) ? config.panelY : 120;
panel.style.left = `${initLeft}px`;
panel.style.top = `${initTop}px`;
panel.innerHTML = `
<div class="op-header" id="op-header">
@ -55,7 +58,7 @@ export function createUI() {
<div class="op-mode-setting" data-setting="minify">
<div class="op-row"><label>Style</label>
<div class="op-row"><input type="radio" name="minify-style" value="dots" id="op-style-dots"><label for="op-style-dots">Dots</label></div>
<div class="op-row"><input type="radio" name="minify-style" value="symbols" id="op-style-symbols"><label for="op-style-symbols">Symbols (slow and buggy, wait 4 fix!)</label></div>
<div class="op-row"><input type="radio" name="minify-style" value="symbols" id="op-style-symbols"><label for="op-style-symbols">Symbols</label></div>
</div>
</div>
</div>
@ -173,11 +176,12 @@ export function createUI() {
}
function rebuildOverlayListUI() {
const list = $('op-overlay-list');
const list = $('op-overlay-list') as HTMLDivElement | null;
if (!list) return;
list.innerHTML = '';
for (const ov of config.overlays) {
const item = document.createElement('div');
item.className = 'op-item' + (ov.id === config.activeOverlayId ? ' active' : '');
item.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"/>
@ -207,7 +211,7 @@ function rebuildOverlayListUI() {
async function addBlankOverlay() {
const name = uniqueName('Overlay', config.overlays.map(o => o.name || ''));
const ov = { id: uid(), name, enabled: true, imageUrl: null, imageBase64: null, isLocal: false, pixelUrl: null, offsetX: 0, offsetY: 0, opacity: 0.7 };
const ov = { id: uid(), name, enabled: true, imageUrl: null as string | null, imageBase64: null as string | null, isLocal: false, pixelUrl: null as string | null, offsetX: 0, offsetY: 0, opacity: 0.7 };
config.overlays.push(ov);
config.activeOverlayId = ov.id;
await saveConfig(['overlays', 'activeOverlayId']);
@ -221,7 +225,7 @@ async function setOverlayImageFromURL(ov: any, url: string) {
await saveConfig(['overlays']); clearOverlayCache();
config.autoCapturePixelUrl = true; await saveConfig(['autoCapturePixelUrl']);
ensureHook(); updateUI();
showToast(`Image loaded. Placement mode ON -- click once to set anchor.`);
showToast('Image loaded. Placement mode ON -- click once to set anchor.');
}
async function setOverlayImageFromFile(ov: any, file: File) {
if (!file || !file.type || !file.type.startsWith('image/')) { alert('Please choose an image file.'); return; }
@ -231,20 +235,43 @@ async function setOverlayImageFromFile(ov: any, file: File) {
await saveConfig(['overlays']); clearOverlayCache();
config.autoCapturePixelUrl = true; await saveConfig(['autoCapturePixelUrl']);
ensureHook(); updateUI();
showToast(`Local image loaded. Placement mode ON -- click once to set anchor.`);
showToast('Local image loaded. Placement mode ON -- click once to set anchor.');
}
type ImportOverlay = {
name?: string;
imageUrl?: string;
pixelUrl?: string | null;
offsetX?: number;
offsetY?: number;
opacity?: number;
};
function isImportOverlay(val: unknown): val is ImportOverlay {
return typeof val === 'object' && val !== null;
}
async function importOverlayFromJSON(jsonText: string) {
let obj; try { obj = JSON.parse(jsonText); } catch { alert('Invalid JSON'); return; }
const arr = Array.isArray(obj) ? obj : [obj];
let parsed: unknown;
try {
parsed = JSON.parse(jsonText) as unknown;
} catch {
alert('Invalid JSON');
return;
}
const arr: ImportOverlay[] = Array.isArray(parsed)
? parsed.filter(isImportOverlay)
: (isImportOverlay(parsed) ? [parsed] : []);
let imported = 0, failed = 0;
for (const item of arr) {
const name = uniqueName(item.name || 'Imported Overlay', config.overlays.map(o => o.name || ''));
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;
const name = uniqueName(item?.name || 'Imported Overlay', config.overlays.map(o => o.name || ''));
const imageUrl = item?.imageUrl;
const pixelUrl = (item?.pixelUrl ?? null) as string | null;
const offsetX = (typeof item?.offsetX === 'number' && Number.isFinite(item.offsetX)) ? item.offsetX : 0;
const offsetY = (typeof item?.offsetY === 'number' && Number.isFinite(item.offsetY)) ? item.offsetY : 0;
const opacity = (typeof item?.opacity === 'number' && Number.isFinite(item.opacity)) ? item.opacity : 0.7;
if (!imageUrl) { failed++; continue; }
try {
const base64 = await urlToDataURL(imageUrl);
@ -268,14 +295,14 @@ function exportActiveOverlayToClipboard() {
copyText(text).then(() => alert('Overlay JSON copied to clipboard!')).catch(() => { prompt('Copy the JSON below:', text); });
}
function copyText(text: string) {
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
return Promise.reject(new Error('Clipboard API not available'));
}
function addEventListeners(panel: HTMLDivElement) {
$('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-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(); });
panel.querySelectorAll('.op-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
@ -288,23 +315,46 @@ function addEventListeners(panel: HTMLDivElement) {
saveConfig(['overlayMode']);
ensureHook();
updateUI();
updatePaletteSymbols();
});
});
$('op-style-dots').addEventListener('change', () => { if (($('op-style-dots') as HTMLInputElement).checked) { config.minifyStyle = 'dots'; saveConfig(['minifyStyle']); clearOverlayCache(); ensureHook(); }});
$('op-style-symbols').addEventListener('change', () => { if (($('op-style-symbols') as HTMLInputElement).checked) { config.minifyStyle = 'symbols'; saveConfig(['minifyStyle']); clearOverlayCache(); ensureHook(); }});
$('op-autocap-toggle').addEventListener('click', () => { config.autoCapturePixelUrl = !config.autoCapturePixelUrl; saveConfig(['autoCapturePixelUrl']); ensureHook(); updateUI(); });
const styleDotsEl = $('op-style-dots') as HTMLInputElement | null;
styleDotsEl?.addEventListener('change', () => {
if (styleDotsEl.checked) {
config.minifyStyle = 'dots';
saveConfig(['minifyStyle']);
clearOverlayCache();
ensureHook();
updatePaletteSymbols();
}
});
$('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-positioning').addEventListener('click', () => { config.collapsePositioning = !config.collapsePositioning; saveConfig(['collapsePositioning']); updateUI(); });
const styleSymbolsEl = $('op-style-symbols') as HTMLInputElement | null;
styleSymbolsEl?.addEventListener('change', () => {
if (styleSymbolsEl.checked) {
config.minifyStyle = 'symbols';
saveConfig(['minifyStyle']);
clearOverlayCache();
ensureHook();
updatePaletteSymbols();
}
});
$('op-name').addEventListener('change', async (e: any) => {
$('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-positioning')?.addEventListener('click', () => { config.collapsePositioning = !config.collapsePositioning; saveConfig(['collapsePositioning']); updateUI(); });
const nameInput = $('op-name') as HTMLInputElement | null;
nameInput?.addEventListener('change', async (e: Event) => {
const ov = getActiveOverlay(); if (!ov) return;
const desired = (e.target.value || '').trim() || 'Overlay';
const target = e.target as HTMLInputElement;
const desired = (target.value || '').trim() || 'Overlay';
if (config.overlays.some(o => o.id !== ov.id && (o.name || '').toLowerCase() === desired.toLowerCase())) {
ov.name = uniqueName(desired, config.overlays.map(o => o.name || ''));
showToast(`Name in use. Renamed to "${ov.name}".`);
@ -312,48 +362,56 @@ function addEventListeners(panel: HTMLDivElement) {
await saveConfig(['overlays']); rebuildOverlayListUI();
});
$('op-fetch').addEventListener('click', async () => {
$('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') as HTMLInputElement ).value.trim(); if (!url) { alert('Enter an image link first.'); return; }
const urlEl = $('op-image-url') as HTMLInputElement | null;
const url = urlEl?.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: any) => {
const file = e.target.files && e.target.files[0]; e.target.value=''; if (!file) return;
const dropzone = $('op-dropzone') as HTMLDivElement | null;
dropzone?.addEventListener('click', () => ( $('op-file-input') as HTMLInputElement | null )?.click());
const fileInput = $('op-file-input') as HTMLInputElement | null;
fileInput?.addEventListener('change', async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target?.files?.[0]; if (target) 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: any) => { e.preventDefault(); e.stopPropagation(); if (evt === 'dragleave' && e.target !== dropzone) return; dropzone.classList.remove('drop-highlight'); }));
dropzone.addEventListener('drop', async (e: any) => {
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.'); }
});
if (dropzone) {
['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: any) => { e.preventDefault(); e.stopPropagation(); if (evt === 'dragleave' && e.target !== dropzone) return; dropzone.classList.remove('drop-highlight'); }));
dropzone.addEventListener('drop', async (e: DragEvent) => {
const dt = e.dataTransfer; if (!dt) return; const file = 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: number, dy: number) => {
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-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: any) => {
const opacitySlider = $('op-opacity-slider') as HTMLInputElement | null;
opacitySlider?.addEventListener('input', (e: Event) => {
const ov = getActiveOverlay(); if (!ov) return;
ov.opacity = parseFloat(e.target.value);
$('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
const target = e.target as HTMLInputElement;
ov.opacity = parseFloat(target.value);
const valEl = $('op-opacity-value');
if (valEl) valEl.textContent = `${Math.round(ov.opacity * 100)}%`;
});
$('op-opacity-slider').addEventListener('change', async () => { await saveConfig(['overlays']); clearOverlayCache(); });
opacitySlider?.addEventListener('change', async () => { await saveConfig(['overlays']); clearOverlayCache(); });
$('op-download-overlay').addEventListener('click', () => {
$('op-download-overlay')?.addEventListener('click', () => {
const ov = getActiveOverlay();
if (!ov || !ov.imageBase64) { showToast('No overlay image to download.'); return; }
const a = document.createElement('a');
@ -364,7 +422,7 @@ function addEventListeners(panel: HTMLDivElement) {
a.remove();
});
$('op-open-cc').addEventListener('click', () => {
$('op-open-cc')?.addEventListener('click', () => {
const ov = getActiveOverlay(); if (!ov || !ov.imageBase64) { showToast('No overlay image to edit.'); return; }
openCCModal(ov);
});
@ -379,7 +437,7 @@ function addEventListeners(panel: HTMLDivElement) {
}
function enableDrag(panel: HTMLDivElement) {
const header = panel.querySelector('#op-header') as HTMLDivElement;
const header = panel.querySelector('#op-header') as HTMLDivElement | null;
if (!header) return;
let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0, moved = false;
@ -396,8 +454,8 @@ function enableDrag(panel: HTMLDivElement) {
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';
panel.style.left = `${clamp(startLeft + dx, 8, maxLeft)}px`;
panel.style.top = `${clamp(startTop + dy, 8, maxTop)}px`;
moved = true;
};
const onPointerUp = (e: PointerEvent) => {
@ -420,46 +478,50 @@ function enableDrag(panel: HTMLDivElement) {
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';
panel.style.left = `${newLeft}px`; panel.style.top = `${newTop}px`;
config.panelX = newLeft; config.panelY = newTop; saveConfig(['panelX', 'panelY']);
});
}
function updateEditorUI() {
const editorSect = $('op-editor-section');
const editorBody = $('op-editor-body');
const editorSect = $('op-editor-section') as HTMLDivElement | null;
const editorBody = $('op-editor-body') as HTMLDivElement | null;
const ov = getActiveOverlay();
if (!editorSect) return;
editorSect.style.display = ov ? 'flex' : 'none';
if (!ov) return;
if (!ov || !editorBody) return;
( $('op-name') as HTMLInputElement ).value = ov.name || '';
const nameEl = $('op-name') as HTMLInputElement | null;
if (nameEl) {
nameEl.value = ov.name || '';
}
const srcWrap = $('op-image-source');
const previewWrap = $('op-preview-wrap');
const previewImg = $('op-image-preview') as HTMLImageElement;
const ccRow = $('op-cc-btn-row');
const srcWrap = $('op-image-source') as HTMLDivElement | null;
const previewWrap = $('op-preview-wrap') as HTMLDivElement | null;
const previewImg = $('op-image-preview') as HTMLImageElement | null;
const ccRow = $('op-cc-btn-row') as HTMLDivElement | null;
if (ov.imageBase64) {
srcWrap.style.display = 'none';
previewWrap.style.display = 'flex';
previewImg.src = ov.imageBase64;
ccRow.style.display = 'flex';
if (srcWrap) srcWrap.style.display = 'none';
if (previewWrap) previewWrap.style.display = 'flex';
if (previewImg) previewImg.src = ov.imageBase64;
if (ccRow) ccRow.style.display = 'flex';
} else {
srcWrap.style.display = 'block';
previewWrap.style.display = 'none';
ccRow.style.display = 'none';
( $('op-image-url') as HTMLInputElement ).value = ov.imageUrl || '';
if (srcWrap) srcWrap.style.display = 'block';
if (previewWrap) previewWrap.style.display = 'none';
if (ccRow) ccRow.style.display = 'none';
const urlInput = $('op-image-url') as HTMLInputElement | null;
if (urlInput) urlInput.value = ov.imageUrl || '';
}
const coords = ov.pixelUrl ? extractPixelCoords(ov.pixelUrl) : { chunk1: '-', chunk2: '-', posX: '-', posY: '-' } as any;
const coordDisplay = $('op-coord-display');
if(coordDisplay) {
if (coordDisplay) {
coordDisplay.textContent = ov.pixelUrl
? `Ref: chunk ${coords.chunk1}/${coords.chunk2} at (${coords.posX}, ${coords.posY})`
: `No pixel anchor set. Enable placement and click a pixel.`;
: 'No pixel anchor set. Enable placement and click a pixel.';
}
const indicator = $('op-offset-indicator');
if (indicator) indicator.textContent = `Offset X ${ov.offsetX}, Y ${ov.offsetY}`;
@ -474,12 +536,14 @@ export function updateUI() {
applyTheme();
const content = $('op-content');
const toggle = $('op-panel-toggle');
const content = $('op-content') as HTMLDivElement | null;
const toggle = $('op-panel-toggle') as HTMLButtonElement | null;
const collapsed = !!config.isPanelCollapsed;
content.style.display = collapsed ? 'none' : 'flex';
toggle.textContent = collapsed ? '▸' : '▾';
toggle.title = collapsed ? 'Expand' : 'Collapse';
if (content) content.style.display = collapsed ? 'none' : 'flex';
if (toggle) {
toggle.textContent = collapsed ? '▸' : '▾';
toggle.title = collapsed ? 'Expand' : 'Collapse';
}
// --- Mode Tabs ---
panelEl.querySelectorAll('.op-tab-btn').forEach(btn => {
@ -490,69 +554,79 @@ export function updateUI() {
} else {
isActive = mode === config.overlayMode;
}
btn.classList.toggle('active', isActive);
(btn as HTMLButtonElement).classList.toggle('active', isActive);
});
// --- Mode Settings ---
const fullOverlaySettings = $('op-mode-settings').querySelector('[data-setting="above"]') as HTMLDivElement;
const minifySettings = $('op-mode-settings').querySelector('[data-setting="minify"]') as HTMLDivElement;
const settingsRoot = $('op-mode-settings') as HTMLDivElement | null;
const fullOverlaySettings = settingsRoot?.querySelector('[data-setting="above"]') as HTMLDivElement | null;
const minifySettings = settingsRoot?.querySelector('[data-setting="minify"]') as HTMLDivElement | null;
if (config.overlayMode === 'above' || config.overlayMode === 'behind') {
fullOverlaySettings.classList.add('active');
minifySettings.classList.remove('active');
fullOverlaySettings?.classList.add('active');
minifySettings?.classList.remove('active');
const ov = getActiveOverlay();
if(ov) {
( $('op-opacity-slider') as HTMLInputElement ).value = String(ov.opacity);
$('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
if (ov) {
const opacitySlider = $('op-opacity-slider') as HTMLInputElement | null;
const opacityVal = $('op-opacity-value');
if (opacitySlider) opacitySlider.value = String(ov.opacity);
if (opacityVal) opacityVal.textContent = `${Math.round(ov.opacity * 100)}%`;
}
} else if (config.overlayMode === 'minify') {
fullOverlaySettings.classList.remove('active');
minifySettings.classList.add('active');
fullOverlaySettings?.classList.remove('active');
minifySettings?.classList.add('active');
} else {
fullOverlaySettings.classList.remove('active');
minifySettings.classList.remove('active');
fullOverlaySettings?.classList.remove('active');
minifySettings?.classList.remove('active');
}
($('op-style-dots') as HTMLInputElement).checked = config.minifyStyle === 'dots';
($('op-style-symbols') as HTMLInputElement).checked = config.minifyStyle === 'symbols';
const dotsEl = $('op-style-dots') as HTMLInputElement | null;
if (dotsEl) dotsEl.checked = config.minifyStyle === 'dots';
const symbolsEl = $('op-style-symbols') as HTMLInputElement | null;
if (symbolsEl) symbolsEl.checked = config.minifyStyle === 'symbols';
const layeringBtns = $('op-layering-btns');
layeringBtns.innerHTML = '';
const behindBtn = document.createElement('button');
behindBtn.textContent = 'Behind';
behindBtn.className = 'op-button' + (config.overlayMode === 'behind' ? ' active' : '');
behindBtn.addEventListener('click', () => { config.overlayMode = 'behind'; saveConfig(['overlayMode']); ensureHook(); updateUI(); });
const aboveBtn = document.createElement('button');
aboveBtn.textContent = 'Above';
aboveBtn.className = 'op-button' + (config.overlayMode === 'above' ? ' active' : '');
aboveBtn.addEventListener('click', () => { config.overlayMode = 'above'; saveConfig(['overlayMode']); ensureHook(); updateUI(); });
layeringBtns.appendChild(behindBtn);
layeringBtns.appendChild(aboveBtn);
const layeringBtns = $('op-layering-btns') as HTMLDivElement | null;
if (layeringBtns) {
layeringBtns.innerHTML = '';
const behindBtn = document.createElement('button');
behindBtn.textContent = 'Behind';
behindBtn.className = `op-button${config.overlayMode === 'behind' ? ' active' : ''}`;
behindBtn.addEventListener('click', () => { config.overlayMode = 'behind'; saveConfig(['overlayMode']); ensureHook(); updateUI(); });
const aboveBtn = document.createElement('button');
aboveBtn.textContent = 'Above';
aboveBtn.className = `op-button${config.overlayMode === 'above' ? ' active' : ''}`;
aboveBtn.addEventListener('click', () => { config.overlayMode = 'above'; saveConfig(['overlayMode']); ensureHook(); updateUI(); });
layeringBtns.appendChild(behindBtn);
layeringBtns.appendChild(aboveBtn);
}
// --- Positioning Section ---
const autoBtn = $('op-autocap-toggle');
const placeLabel = $('op-place-label');
autoBtn.textContent = config.autoCapturePixelUrl ? 'Enabled' : 'Disabled';
autoBtn.classList.toggle('op-danger', !!config.autoCapturePixelUrl);
if(placeLabel) placeLabel.classList.toggle('op-danger-text', !!config.autoCapturePixelUrl);
if (autoBtn) {
autoBtn.textContent = config.autoCapturePixelUrl ? 'Enabled' : 'Disabled';
autoBtn.classList.toggle('op-danger', !!config.autoCapturePixelUrl);
}
if (placeLabel) placeLabel.classList.toggle('op-danger-text', !!config.autoCapturePixelUrl);
const positioningBody = $('op-positioning-body');
const positioningCz = $('op-collapse-positioning');
if(positioningBody) positioningBody.style.display = config.collapsePositioning ? 'none' : 'block';
if (positioningCz) positioningCz.textContent = config.collapsePositioning ? '▸' : '▾';
if (positioningBody) positioningBody.style.display = config.collapsePositioning ? 'none' : 'block';
if (positioningCz) (positioningCz as HTMLButtonElement).textContent = config.collapsePositioning ? '▸' : '▾';
const listWrap = $('op-list-wrap');
const listWrap = $('op-list-wrap') as HTMLDivElement | null;
const listCz = $('op-collapse-list');
listWrap.style.display = config.collapseList ? 'none' : 'block';
if (listCz) listCz.textContent = config.collapseList ? '▸' : '▾';
if (listWrap) listWrap.style.display = config.collapseList ? 'none' : 'block';
if (listCz) (listCz as HTMLButtonElement).textContent = config.collapseList ? '▸' : '▾';
rebuildOverlayListUI();
updateEditorUI();
const exportBtn = $('op-export-overlay') as HTMLButtonElement;
const exportBtn = $('op-export-overlay') as HTMLButtonElement | null;
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';
const canExport = !!(ov?.imageUrl && !ov?.isLocal);
if (exportBtn) {
exportBtn.disabled = !canExport;
exportBtn.title = canExport ? 'Export active overlay JSON' : 'Export disabled for local images';
}
}

View file

@ -1,6 +1,6 @@
/// <reference types="tampermonkey" />
import { createCanvas, createHTMLCanvas, canvasToDataURLSafe, loadImage } from '../core/canvas';
import { config, saveConfig } from '../core/store';
import { saveConfig } from '../core/store';
import { MAX_OVERLAY_DIM } from '../core/constants';
import { ensureHook } from '../core/hook';
import { clearOverlayCache } from '../core/cache';
@ -291,10 +291,26 @@ export function buildRSModal() {
closeBtn: modal.querySelector('#op-rs-close') as HTMLButtonElement,
};
const ctxPrev = refs.preview.getContext('2d', { willReadFrequently: true })!;
const ctxSimOrig = refs.simOrig.getContext('2d', { willReadFrequently: true })!;
const ctxSimNew = refs.simNew.getContext('2d', { willReadFrequently: true })!;
const ctxRes = refs.resCanvas.getContext('2d', { willReadFrequently: true })!;
const ctxPrev: CanvasRenderingContext2D = (() => {
const ctx = refs.preview.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Failed to get 2d context for preview canvas.');
return ctx;
})();
const ctxSimOrig: CanvasRenderingContext2D = (() => {
const ctx = refs.simOrig.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Failed to get 2d context for simOrig canvas.');
return ctx;
})();
const ctxSimNew: CanvasRenderingContext2D = (() => {
const ctx = refs.simNew.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Failed to get 2d context for simNew canvas.');
return ctx;
})();
const ctxRes: CanvasRenderingContext2D = (() => {
const ctx = refs.resCanvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Failed to get 2d context for resCanvas.');
return ctx;
})();
rs = {
...refs,
@ -321,65 +337,70 @@ export function buildRSModal() {
calcReady: false,
};
const s = rs; // local non-null alias within this scope
function 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 W = parseInt(s.w.value || '0', 10);
const H = parseInt(s.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.';
}
function sampleDims() {
const cols = Math.floor((rs!.origW - rs!.offx) / rs!.gapX);
const rows = Math.floor((rs!.origH - rs!.offy) / rs!.gapY);
const cols = Math.floor((s.origW - s.offx) / s.gapX);
const rows = Math.floor((s.origH - s.offy) / s.gapY);
return { cols: Math.max(0, cols), rows: Math.max(0, rows) };
}
function computeAdvancedFooterText() {
const { cols, rows } = sampleDims();
const limit = (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM);
return (cols>0 && rows>0)
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();
s.meta.textContent = (s.mode === 'advanced') ? computeAdvancedFooterText() : computeSimpleFooterText();
};
function drawSimplePreview() {
if (!rs!.img) return;
const leftLabelH = rs!.colLeft.querySelector('.pad-top')!.clientHeight;
const rightLabelH = rs!.colRight.querySelector('.pad-top')!.clientHeight;
const leftW = rs!.colLeft.clientWidth;
const rightW = rs!.colRight.clientWidth;
const leftH = rs!.colLeft.clientHeight - leftLabelH;
const rightH = rs!.colRight.clientHeight - rightLabelH;
if (!s.img) return;
const padTopL = s.colLeft.querySelector('.pad-top') as HTMLElement | null;
const padTopR = s.colRight.querySelector('.pad-top') as HTMLElement | null;
const leftLabelH = padTopL ? padTopL.clientHeight : 0;
const rightLabelH = padTopR ? padTopR.clientHeight : 0;
const leftW = s.colLeft.clientWidth;
const rightW = s.colRight.clientWidth;
const leftH = s.colLeft.clientHeight - leftLabelH;
const rightH = s.colRight.clientHeight - rightLabelH;
rs!.simOrig.width = leftW; rs!.simOrig.height = leftH;
rs!.simNew.width = rightW; rs!.simNew.height = rightH;
s.simOrig.width = leftW; s.simOrig.height = leftH;
s.simNew.width = rightW; s.simNew.height = rightH;
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 sFit = Math.min(leftW / s.origW, leftH / s.origH);
const dW = Math.max(1, Math.floor(s.origW * sFit));
const dH = Math.max(1, Math.floor(s.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.drawImage(s.img, 0,0, s.origW,s.origH, dx0,dy0, dW,dH);
ctxSimOrig.restore();
const W = parseInt(rs!.w.value||'0',10);
const H = parseInt(rs!.h.value||'0',10);
const W = parseInt(s.w.value || '0', 10);
const H = parseInt(s.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) as any;
const tctx = tiny.getContext('2d', { willReadFrequently: true })!;
const tiny = createCanvas(W, H) as HTMLCanvasElement;
const tctx = tiny.getContext('2d', { willReadFrequently: true });
if (!tctx) throw new Error('Failed to get 2d context for tiny canvas.');
tctx.imageSmoothingEnabled = false;
tctx.clearRect(0,0,W,H);
tctx.drawImage(rs!.img!, 0,0, rs!.origW,rs!.origH, 0,0, W,H);
tctx.drawImage(s.img, 0,0, s.origW,s.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; }
@ -392,64 +413,63 @@ export function buildRSModal() {
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.drawImage(s.img, 0,0, s.origW,s.origH, dx0,dy0, dW,dH);
}
ctxSimNew.restore();
}
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;
sampleDims();
if (s.mode === 'advanced') {
s.applyBtn.disabled = !s.calcReady;
} else {
const W = parseInt(rs!.w.value||'0',10), H = parseInt(rs!.h.value||'0',10);
const W = parseInt(s.w.value||'0',10), H = parseInt(s.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;
s.applyBtn.disabled = !ok;
}
updateFooterMeta();
}
function drawAdvancedPreview() {
if (rs!.mode !== 'advanced' || !rs!.img) return;
const w = rs!.origW, h = rs!.origH;
if (s.mode !== 'advanced' || !s.img) return;
const w = s.origW, h = s.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 destW = Math.max(50, Math.floor(s.advWrap.clientWidth));
const destH = Math.max(50, Math.floor(s.advWrap.clientHeight));
s.preview.width = destW;
s.preview.height = destH;
const sw = Math.max(1, Math.floor(destW / rs!.zoom));
const sh = Math.max(1, Math.floor(destH / rs!.zoom));
const sw = Math.max(1, Math.floor(destW / s.zoom));
const sh = Math.max(1, Math.floor(destH / s.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);
s.viewX = Math.min(Math.max(0, s.viewX), maxX);
s.viewY = Math.min(Math.max(0, s.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);
ctxPrev.drawImage(s.img, s.viewX, s.viewY, sw, sh, 0, 0, destW, destH);
if (rs!.gridToggle.checked && rs!.gapX >= 1 && rs!.gapY >= 1) {
if (s.gridToggle.checked && s.gapX >= 1 && s.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 startGX = Math.ceil((s.viewX - s.offx) / s.gapX);
const endGX = Math.floor((s.viewX + sw - s.offx) / s.gapX);
const startGY = Math.ceil((s.viewY - s.offy) / s.gapY);
const endGY = Math.floor((s.viewY + sh - s.offy) / s.gapY);
const linesX = Math.max(0, endGX - startGX + 1);
const linesY = Math.max(0, endGY - startGY + 1);
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);
const x = s.offx + gx * s.gapX;
const px = Math.round((x - s.viewX) * s.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);
const y = s.offy + gy * s.gapY;
const py = Math.round((y - s.viewY) * s.zoom);
ctxPrev.moveTo(0, py + 0.5);
ctxPrev.lineTo(destW, py + 0.5);
}
@ -457,26 +477,26 @@ export function buildRSModal() {
}
}
if (rs!.gapX >= 1 && rs!.gapY >= 1) {
if (s.gapX >= 1 && s.gapY >= 1) {
ctxPrev.fillStyle = '#ff3b30';
const cx0 = rs!.offx + Math.floor(rs!.gapX/2);
const cy0 = rs!.offy + Math.floor(rs!.gapY/2);
const cx0 = s.offx + Math.floor(s.gapX/2);
const cy0 = s.offy + Math.floor(s.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 startX = Math.ceil((s.viewX - cx0) / s.gapX);
const startY = Math.ceil((s.viewY - cy0) / s.gapY);
const endY = Math.floor((s.viewY + sh - 1 - cy0) / s.gapY);
const endX2 = Math.floor((s.viewX + sw - 1 - cx0) / s.gapX);
const r = s.dotr;
const dotsX = Math.max(0, endX2 - startX + 1);
const dotsY = Math.max(0, endY - startY + 1);
const maxDots = 300000;
if (dotsX * dotsY <= maxDots) {
for (let gy = startY; gy <= endY; gy++) {
const y = cy0 + gy * rs!.gapY;
const y = cy0 + gy * s.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);
const x = cx0 + gx * s.gapX;
const px = Math.round((x - s.viewX) * s.zoom);
const py = Math.round((y - s.viewY) * s.zoom);
ctxPrev.beginPath();
ctxPrev.arc(px, py, r, 0, Math.PI*2);
ctxPrev.fill();
@ -489,44 +509,44 @@ export function buildRSModal() {
}
function drawAdvancedResultPreview() {
const canvas = rs!.calcCanvas;
const wrap = rs!.resWrap;
const canvas = s.calcCanvas;
const wrap = s.resWrap;
if (!wrap || !canvas) {
ctxRes.clearRect(0,0, rs!.resCanvas.width, rs!.resCanvas.height);
rs!.resMeta.textContent = 'No result. Click Calculate.';
ctxRes.clearRect(0,0, s.resCanvas.width, s.resCanvas.height);
s.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;
const scale = Math.min(availW / W, availH / H);
const dW = Math.max(1, Math.floor(W * scale));
const dH = Math.max(1, Math.floor(H * scale));
s.resCanvas.width = dW;
s.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})` : ''}`;
s.resMeta.textContent = `Output: ${W}×${H}${(W>=MAX_OVERLAY_DIM||H>=MAX_OVERLAY_DIM) ? ` (exceeds limit: < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})` : ''}`;
}
rs._drawSimplePreview = drawSimplePreview;
rs._drawAdvancedPreview = drawAdvancedPreview;
rs._drawAdvancedResultPreview = drawAdvancedResultPreview;
s._drawSimplePreview = drawSimplePreview;
s._drawAdvancedPreview = drawAdvancedPreview;
s._drawAdvancedResultPreview = drawAdvancedResultPreview;
const setMode = (m: 'simple'|'advanced') => {
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');
s.mode = m;
s.tabSimple.classList.toggle('active', m === 'simple');
s.tabAdvanced.classList.toggle('active', m === 'advanced');
s.paneSimple.classList.toggle('show', m === 'simple');
s.paneAdvanced.classList.toggle('show', m === 'advanced');
updateFooterMeta();
rs!.calcBtn.style.display = (m === 'advanced') ? 'inline-block' : 'none';
s.calcBtn.style.display = (m === 'advanced') ? 'inline-block' : 'none';
if (m === 'advanced') {
rs!.applyBtn.disabled = !rs!.calcReady;
s.applyBtn.disabled = !s.calcReady;
} else {
syncSimpleNote();
}
@ -539,144 +559,144 @@ export function buildRSModal() {
drawSimplePreview();
}
};
rs._setMode = (m) => {
s._setMode = (m) => {
const evt = new Event('click');
(m === 'simple' ? rs!.tabSimple : rs!.tabAdvanced).dispatchEvent(evt);
(m === 'simple' ? s.tabSimple : s.tabAdvanced).dispatchEvent(evt);
};
rs.tabSimple.addEventListener('click', () => setMode('simple'));
rs.tabAdvanced.addEventListener('click', () => setMode('advanced'));
s.tabSimple.addEventListener('click', () => setMode('simple'));
s.tabAdvanced.addEventListener('click', () => setMode('advanced'));
function 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 = String(Math.max(1, Math.round(W * rs!.origH / rs!.origW)));
if (s.updating) return;
s.updating = true;
const W = parseInt(s.w.value||'0',10);
if (s.lock.checked && s.origW>0 && s.origH>0 && W>0) {
s.h.value = String(Math.max(1, Math.round(W * s.origH / s.origW)));
}
rs!.updating = false;
s.updating = false;
syncSimpleNote();
if (rs!.mode === 'simple') drawSimplePreview();
if (s.mode === 'simple') drawSimplePreview();
}
function 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 = String(Math.max(1, Math.round(H * rs!.origW / rs!.origH)));
if (s.updating) return;
s.updating = true;
const H = parseInt(s.h.value||'0',10);
if (s.lock.checked && s.origW>0 && s.origH>0 && H>0) {
s.w.value = String(Math.max(1, Math.round(H * s.origW / s.origH)));
}
rs!.updating = false;
s.updating = false;
syncSimpleNote();
if (rs!.mode === 'simple') drawSimplePreview();
if (s.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);
s.w.addEventListener('input', onWidthInput);
s.h.addEventListener('input', onHeightInput);
s.onex.addEventListener('click', () => { applyScaleToFields(1); drawSimplePreview(); });
s.half.addEventListener('click', () => { applyScaleToFields(0.5); drawSimplePreview(); });
s.third.addEventListener('click', () => { applyScaleToFields(1/3); drawSimplePreview(); });
s.quarter.addEventListener('click', () => { applyScaleToFields(1/4); drawSimplePreview(); });
s.double.addEventListener('click', () => { applyScaleToFields(2); drawSimplePreview(); });
s.applyScale.addEventListener('click', () => {
const scaleVal = parseFloat(s.scale.value||'');
if (!Number.isFinite(scaleVal) || scaleVal<=0) { showToast('Enter a valid scale factor > 0'); return; }
applyScaleToFields(scaleVal);
drawSimplePreview();
});
const markCalcStale = () => {
if (rs!.mode === 'advanced') {
rs!.calcReady = false;
rs!.applyBtn.disabled = true;
if (s.mode === 'advanced') {
s.calcReady = false;
s.applyBtn.disabled = true;
drawAdvancedResultPreview();
updateFooterMeta();
}
};
const onMultChange = (v: string) => {
if (rs!.updating) return;
if (s.updating) return;
const parsed = parseFloat(v);
if (!Number.isFinite(parsed)) return;
const clamped = Math.min(Math.max(parsed, 1), 128);
rs!.mult = clamped;
if (rs!.bind.checked) { rs!.gapX = clamped; rs!.gapY = clamped; }
s.mult = clamped;
if (s.bind.checked) { s.gapX = clamped; s.gapY = clamped; }
syncAdvFieldsToState();
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
};
rs.multRange.addEventListener('input', (e) => onMultChange((e.target as HTMLInputElement).value));
rs.multInput.addEventListener('input', (e) => {
s.multRange.addEventListener('input', (e) => onMultChange((e.target as HTMLInputElement).value));
s.multInput.addEventListener('input', (e) => {
const v = (e.target as HTMLInputElement).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(); }
s.bind.addEventListener('change', () => {
if (s.bind.checked) { s.gapX = s.mult; s.gapY = s.mult; syncAdvFieldsToState(); }
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.blockW.addEventListener('input', (e) => {
s.blockW.addEventListener('input', (e) => {
const val = parseFloat((e.target as HTMLInputElement).value);
if (!Number.isFinite(val)) return;
rs!.gapX = Math.min(Math.max(val, 1), 4096);
if (rs!.bind.checked) { rs!.mult = rs!.gapX; rs!.gapY = rs!.gapX; }
s.gapX = Math.min(Math.max(val, 1), 4096);
if (s.bind.checked) { s.mult = s.gapX; s.gapY = s.gapX; }
syncAdvFieldsToState();
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.blockH.addEventListener('input', (e) => {
s.blockH.addEventListener('input', (e) => {
const val = parseFloat((e.target as HTMLInputElement).value);
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; }
s.gapY = Math.min(Math.max(val, 1), 4096);
if (s.bind.checked) { s.mult = s.gapY; s.gapX = s.gapY; }
syncAdvFieldsToState();
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.offX.addEventListener('input', (e) => {
s.offX.addEventListener('input', (e) => {
const val = parseFloat((e.target as HTMLInputElement).value);
if (!Number.isFinite(val)) return;
rs!.offx = Math.min(Math.max(val, 0), Math.max(0, rs!.origH-0.0001));
rs!.viewX = Math.min(rs!.viewX, Math.max(0, rs!.origW - 1));
s.offx = Math.min(Math.max(val, 0), Math.max(0, s.origH-0.0001));
s.viewX = Math.min(s.viewX, Math.max(0, s.origW - 1));
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.offY.addEventListener('input', (e) => {
s.offY.addEventListener('input', (e) => {
const val = parseFloat((e.target as HTMLInputElement).value);
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));
s.offy = Math.min(Math.max(val, 0), Math.max(0, s.origH-0.0001));
s.viewY = Math.min(s.viewY, Math.max(0, s.origH - 1));
syncAdvancedMeta();
drawAdvancedPreview();
markCalcStale();
});
rs.dotR.addEventListener('input', (e) => {
rs!.dotr = Math.max(1, Math.round(Number((e.target as HTMLInputElement).value)||1));
rs!.dotRVal.textContent = String(rs!.dotr);
s.dotR.addEventListener('input', (e) => {
s.dotr = Math.max(1, Math.round(Number((e.target as HTMLInputElement).value)||1));
s.dotRVal.textContent = String(s.dotr);
drawAdvancedPreview();
});
rs.gridToggle.addEventListener('change', drawAdvancedPreview);
s.gridToggle.addEventListener('change', drawAdvancedPreview);
function applyZoom(factor: number) {
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));
const destW = Math.max(50, Math.floor(s.advWrap.clientWidth));
const destH = Math.max(50, Math.floor(s.advWrap.clientHeight));
const sw = Math.max(1, Math.floor(destW / s.zoom));
const sh = Math.max(1, Math.floor(destH / s.zoom));
const cx = s.viewX + sw / 2;
const cy = s.viewY + sh / 2;
s.zoom = Math.min(32, Math.max(0.1, s.zoom * factor));
const sw2 = Math.max(1, Math.floor(destW / s.zoom));
const sh2 = Math.max(1, Math.floor(destH / s.zoom));
s.viewX = Math.min(Math.max(0, Math.round(cx - sw2 / 2)), Math.max(0, s.origW - sw2));
s.viewY = Math.min(Math.max(0, Math.round(cy - sh2 / 2)), Math.max(0, s.origH - sh2));
drawAdvancedPreview();
}
rs.zoomIn.addEventListener('click', () => applyZoom(1.25));
rs.zoomOut.addEventListener('click', () => applyZoom(1/1.25));
rs.advWrap.addEventListener('wheel', (e) => {
s.zoomIn.addEventListener('click', () => applyZoom(1.25));
s.zoomOut.addEventListener('click', () => applyZoom(1/1.25));
s.advWrap.addEventListener('wheel', (e) => {
if (!(e as WheelEvent).ctrlKey) return;
e.preventDefault();
const delta = (e as WheelEvent).deltaY || 0;
@ -685,59 +705,59 @@ export function buildRSModal() {
const onPanDown = (e: PointerEvent) => {
if ((e.target as HTMLElement).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 as any).setPointerCapture?.(e.pointerId);
s.panning = true;
s.panStart = { x: e.clientX, y: e.clientY, viewX: s.viewX, viewY: s.viewY };
s.advWrap.classList.remove('op-pan-grab');
s.advWrap.classList.add('op-pan-grabbing');
(s.advWrap as any).setPointerCapture?.(e.pointerId);
};
const onPanMove = (e: PointerEvent) => {
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;
if (!s.panning || !s.panStart) return;
const dx = e.clientX - s.panStart.x;
const dy = e.clientY - s.panStart.y;
const wrapW = s.advWrap.clientWidth;
const wrapH = s.advWrap.clientHeight;
const sw = Math.max(1, Math.floor(wrapW / s.zoom));
const sh = Math.max(1, Math.floor(wrapH / s.zoom));
let nx = s.panStart.viewX - Math.round(dx / s.zoom);
let ny = s.panStart.viewY - Math.round(dy / s.zoom);
nx = Math.min(Math.max(0, nx), Math.max(0, s.origW - sw));
ny = Math.min(Math.max(0, ny), Math.max(0, s.origH - sh));
s.viewX = nx;
s.viewY = ny;
drawAdvancedPreview();
};
const onPanUp = (e: PointerEvent) => {
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 as any).releasePointerCapture?.(e.pointerId);
if (!s.panning) return;
s.panning = false;
s.panStart = null;
s.advWrap.classList.remove('op-pan-grabbing');
s.advWrap.classList.add('op-pan-grab');
(s.advWrap as any).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);
s.advWrap.addEventListener('pointerdown', onPanDown);
s.advWrap.addEventListener('pointermove', onPanMove);
s.advWrap.addEventListener('pointerup', onPanUp);
s.advWrap.addEventListener('pointercancel', onPanUp);
s.advWrap.addEventListener('pointerleave', onPanUp);
const close = () => closeRSModal();
rs.cancelBtn.addEventListener('click', close);
rs.closeBtn.addEventListener('click', close);
s.cancelBtn.addEventListener('click', close);
s.closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', close);
rs.calcBtn.addEventListener('click', async () => {
if (rs!.mode !== 'advanced' || !rs!.img) return;
s.calcBtn.addEventListener('click', async () => {
if (s.mode !== 'advanced' || !s.img) 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;
const canvas = await reconstructViaGrid(s.img, s.origW, s.origH, s.offx, s.offy, s.gapX, s.gapY);
s.calcCanvas = canvas;
s.calcCols = cols;
s.calcRows = rows;
s.calcReady = true;
s.applyBtn.disabled = false;
drawAdvancedResultPreview();
updateFooterMeta();
showToast(`Calculated ${cols}×${rows}. Review preview, then Apply.`);
@ -747,29 +767,29 @@ export function buildRSModal() {
}
});
rs.applyBtn.addEventListener('click', async () => {
if (!rs!.ov) return;
s.applyBtn.addEventListener('click', async () => {
if (!s.ov) return;
try {
if (rs!.mode === 'simple') {
const W = parseInt(rs!.w.value||'0',10);
const H = parseInt(rs!.h.value||'0',10);
if (s.mode === 'simple') {
const W = parseInt(s.w.value||'0',10);
const H = parseInt(s.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);
await resizeOverlayImage(s.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;
if (!s.calcReady || !s.calcCanvas) { showToast('Calculate first.'); return; }
const dataUrl = await canvasToDataURLSafe(s.calcCanvas);
s.ov.imageBase64 = dataUrl;
s.ov.imageUrl = null;
s.ov.isLocal = true;
await saveConfig(['overlays']);
clearOverlayCache();
ensureHook();
emitOverlayChanged();
closeRSModal();
showToast(`Applied ${rs!.calcCols}×${rs!.calcRows}.`);
showToast(`Applied ${s.calcCols}×${s.calcRows}.`);
}
} catch (e) {
console.error(e);
@ -778,51 +798,52 @@ export function buildRSModal() {
});
function syncSimpleNote() {
const W = parseInt(rs!.w.value||'0',10);
const H = parseInt(rs!.h.value||'0',10);
const W = parseInt(s.w.value||'0',10);
const H = parseInt(s.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;
if (s.note) s.note.textContent = simpleText;
if (s.mode === 'simple') s.applyBtn.disabled = (!ok || limit);
if (s.mode === 'simple') s.meta.textContent = simpleText;
}
function applyScaleToFields(scale: number) {
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 = String(W);
rs!.h.value = rs!.lock.checked ? String(Math.max(1, Math.round(W * rs!.origH / rs!.origW))) : String(H);
rs!.updating = false;
const W = Math.max(1, Math.round(s.origW * scale));
const H = Math.max(1, Math.round(s.origH * scale));
s.updating = true;
s.w.value = String(W);
s.h.value = s.lock.checked ? String(Math.max(1, Math.round(W * s.origH / s.origW))) : String(H);
s.updating = false;
syncSimpleNote();
}
function 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;
s.updating = true;
s.multRange.value = String(s.mult);
s.multInput.value = String(s.mult);
s.blockW.value = String(s.gapX);
s.blockH.value = String(s.gapY);
s.offX.value = String(s.offx);
s.offY.value = String(s.offy);
s.dotR.value = String(s.dotr);
s.dotRVal.textContent = String(s.dotr);
s.updating = false;
}
rs._syncAdvancedMeta = syncAdvancedMeta;
rs._syncSimpleNote = syncSimpleNote;
s._syncAdvancedMeta = syncAdvancedMeta;
s._syncSimpleNote = syncSimpleNote;
rs._resizeHandler = () => {
if (rs!.mode === 'simple') rs!._drawSimplePreview?.();
s._resizeHandler = () => {
if (!rs) return;
if (rs.mode === 'simple') rs._drawSimplePreview?.();
else {
rs!._drawAdvancedPreview?.();
rs!._drawAdvancedResultPreview?.();
rs._drawAdvancedPreview?.();
rs._drawAdvancedResultPreview?.();
}
};
window.addEventListener('resize', rs._resizeHandler);
window.addEventListener('resize', s._resizeHandler);
}
export function openRSModal(overlay: any) {
@ -831,60 +852,64 @@ export function openRSModal(overlay: any) {
const img = new Image();
img.onload = () => {
rs!.img = img;
rs!.origW = img.width; rs!.origH = img.height;
const s = rs;
if (!s) return;
rs!.orig.value = `${rs!.origW}×${rs!.origH}`;
rs!.w.value = String(rs!.origW);
rs!.h.value = String(rs!.origH);
rs!.lock.checked = true;
s.img = img;
s.origW = img.width; s.origH = img.height;
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;
s.orig.value = `${s.origW}×${s.origH}`;
s.w.value = String(s.origW);
s.h.value = String(s.origH);
s.lock.checked = true;
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;
s.zoom = 1.0;
s.mult = 4;
s.gapX = 4; s.gapY = 4;
s.offx = 0; s.offy = 0;
s.dotr = 1;
s.viewX = 0; s.viewY = 0;
rs!.calcCanvas = null;
rs!.calcCols = 0;
rs!.calcRows = 0;
rs!.calcReady = false;
rs!.applyBtn.disabled = (rs!.mode === 'advanced');
s.bind.checked = true;
s.multRange.value = '4';
s.multInput.value = '4';
s.blockW.value = '4';
s.blockH.value = '4';
s.offX.value = '0';
s.offY.value = '0';
s.dotR.value = '1';
s.dotRVal.textContent = '1';
s.gridToggle.checked = true;
rs!._setMode!('simple');
s.calcCanvas = null;
s.calcCols = 0;
s.calcRows = 0;
s.calcReady = false;
s.applyBtn.disabled = (s.mode === 'advanced');
if (s._setMode) s._setMode('simple');
document.body.classList.add('op-scroll-lock');
rs!.backdrop.classList.add('show');
rs!.modal.style.display = 'flex';
s.backdrop.classList.add('show');
s.modal.style.display = 'flex';
rs!._drawSimplePreview?.();
rs!._drawAdvancedPreview?.();
rs!._drawAdvancedResultPreview?.();
rs!._syncAdvancedMeta?.();
rs!._syncSimpleNote?.();
s._drawSimplePreview?.();
s._drawAdvancedPreview?.();
s._drawAdvancedResultPreview?.();
s._syncAdvancedMeta?.();
s._syncSimpleNote?.();
const setFooterNow = () => {
if (rs!.mode === 'advanced') {
const cols = Math.floor((rs!.origW - rs!.offx) / rs!.gapX);
const rows = Math.floor((rs!.origH - rs!.offy) / rs!.gapY);
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.';
if (!rs) return;
if (rs.mode === 'advanced') {
const cols = Math.floor((rs.origW - rs.offx) / rs.gapX);
const rows = Math.floor((rs.origH - rs.offy) / rs.gapY);
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 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.';
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();
@ -903,8 +928,9 @@ function closeRSModal() {
}
async function reconstructViaGrid(img: HTMLImageElement, origW: number, origH: number, offx: number, offy: number, gapX: number, gapY: number) {
const srcCanvas = createCanvas(origW, origH) as any;
const sctx = srcCanvas.getContext('2d', { willReadFrequently: true })!;
const srcCanvas = createCanvas(origW, origH) as HTMLCanvasElement;
const sctx = srcCanvas.getContext('2d', { willReadFrequently: true });
if (!sctx) throw new Error('Failed to get 2d context for srcCanvas.');
sctx.imageSmoothingEnabled = false;
sctx.drawImage(img, 0, 0);
const srcData = sctx.getImageData(0,0,origW,origH).data;
@ -914,7 +940,8 @@ async function reconstructViaGrid(img: HTMLImageElement, origW: number, origH: n
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 octx = outCanvas.getContext('2d');
if (!octx) throw new Error('Failed to get 2d context for outCanvas.');
const out = octx.createImageData(cols, rows);
const odata = out.data;
@ -948,7 +975,8 @@ async function reconstructViaGrid(img: HTMLImageElement, origW: number, origH: n
async function resizeOverlayImage(ov: any, targetW: number, targetH: number) {
const img = await loadImage(ov.imageBase64);
const canvas = createHTMLCanvas(targetW, targetH);
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Failed to get 2d context for resize canvas.');
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0,0,targetW,targetH);
ctx.drawImage(img, 0,0, img.width,img.height, 0,0, targetW,targetH);