From 812181c2046c5194184a1b5e72c03c2408da9740 Mon Sep 17 00:00:00 2001 From: Decrypt Date: Fri, 22 Aug 2025 15:26:28 +0200 Subject: [PATCH 01/12] fix: optimize symbols mode performance and fix rendering bugs - Fix major performance bottleneck in symbols mode by iterating only over intersection areas instead of full 1000x1000 tiles - Optimize memory usage by creating output canvas only for intersection area - Fix coordinate transformation logic bugs in symbol placement - Remove redundant calculations and improve bounds checking - Clean up TypeScript non-null assertions and unused code - Symbols mode now renders significantly faster and without visual artifacts --- .gitignore | 4 +- esbuild.config.mjs | 2 +- src/core/cache.ts | 7 ++- src/core/canvas.ts | 3 +- src/core/overlay.ts | 114 +++++++++++++++++++++++++++----------------- src/main.ts | 4 +- src/meta.js | 2 +- src/ui/panel.ts | 110 ++++++++++++++++++++++++------------------ src/ui/rsModal.ts | 17 ++++--- 9 files changed, 156 insertions(+), 107 deletions(-) diff --git a/.gitignore b/.gitignore index d50a184..b0af9b5 100644 --- a/.gitignore +++ b/.gitignore @@ -127,4 +127,6 @@ yarn/cache yarn/unplugged yarn/build-state.yml yarn/install-state.gz -pnp.* \ No newline at end of file +pnp.* +.DS_Store +bun.lock diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 3f5e30e..51bdfc3 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -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([ diff --git a/src/core/cache.ts b/src/core/cache.ts index 0379711..21e2dcb 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -4,8 +4,11 @@ export class LRUCache { 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) { diff --git a/src/core/canvas.ts b/src/core/canvas.ts index 609b426..58d32dc 100644 --- a/src/core/canvas.ts +++ b/src/core/canvas.ts @@ -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'); } diff --git a/src/core/overlay.ts b/src/core/overlay.ts index 7fd740a..98ff907 100644 --- a/src/core/overlay.ts +++ b/src/core/overlay.ts @@ -105,7 +105,8 @@ function isPalettePerfectImage(img: HTMLImageElement): boolean { 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 }); + 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; @@ -195,7 +196,8 @@ export async function buildOverlayDataForChunkUnified( 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 })!; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Failed to get 2D context'); ctx.drawImage(img as any, drawX, drawY); const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h); @@ -231,31 +233,45 @@ export async function buildOverlayDataForChunkUnified( 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(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); + // Get original image data once + const sourceCanvas = createCanvas(wImg, hImg) as any; + const sourceCtx = sourceCanvas.getContext('2d', { willReadFrequently: true }); + if (!sourceCtx) throw new Error('Failed to get 2D context'); + sourceCtx.imageSmoothingEnabled = false; + sourceCtx.drawImage(img as any, 0, 0); + const originalImageData = sourceCtx.getImageData(0, 0, wImg, hImg); + const srcData = originalImageData.data; - const outCanvas = createCanvas(tileW, tileH) as any; - const outCtx = outCanvas.getContext('2d', { willReadFrequently: true })!; - const outputImageData = outCtx.createImageData(tileW, tileH); + // Create output canvas for just the intersection area + const outCanvas = createCanvas(isect.w, isect.h) as any; + const outCtx = outCanvas.getContext('2d', { willReadFrequently: true }); + if (!outCtx) throw new Error('Failed to get 2D context'); + const outputImageData = outCtx.createImageData(isect.w, isect.h); const outData = outputImageData.data; - // Precompute symbol centering offsets for performance + // Precompute symbol centering offsets 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; + // Convert intersection back to tile coordinates + const startTileX = Math.floor(isect.x / scale); + const startTileY = Math.floor(isect.y / scale); + const endTileX = Math.floor((isect.x + isect.w - 1) / scale); + const endTileY = Math.floor((isect.y + isect.h - 1) / scale); + + // Only iterate over tiles that intersect + for (let tileY = startTileY; tileY <= endTileY; tileY++) { + for (let tileX = startTileX; tileX <= endTileX; tileX++) { + // Convert back to original image coordinates + const imgX = tileX - drawX; + const imgY = tileY - 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]; + const srcIdx = (imgY * wImg + imgX) * 4; + const r = srcData[srcIdx]; + const g = srcData[srcIdx + 1]; + const b = srcData[srcIdx + 2]; + const a = srcData[srcIdx + 3]; // Early exit for transparent or deface pixels if (a <= 128 || (r === 0xde && g === 0xfa && b === 0xce)) continue; @@ -273,28 +289,33 @@ export async function buildOverlayDataForChunkUnified( 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]; + const pR = paletteColor[0]; + const pG = paletteColor[1]; + const pB = paletteColor[2]; + // Calculate tile position in scaled coordinates + const tileXScaled = tileX * scale; + const tileYScaled = tileY * scale; + + // Draw symbol 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; + const outX = tileXScaled + sx + centerX - isect.x; + const outY = tileYScaled + sy + centerY - isect.y; + + if (outX >= 0 && outX < isect.w && outY >= 0 && outY < isect.h) { + const outIdx = (outY * isect.w + outX) * 4; + outData[outIdx] = pR; + outData[outIdx + 1] = pG; + outData[outIdx + 2] = pB; + outData[outIdx + 3] = 255; } } } @@ -303,12 +324,10 @@ export async function buildOverlayDataForChunkUnified( } } } + 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 }; + const result = { imageData: outputImageData, dx: isect.x, dy: isect.y, scaled: true, scale }; overlayCache.set(cacheKey, result); return result; } else { // 'dots' @@ -324,7 +343,8 @@ export async function buildOverlayDataForChunkUnified( 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 })!; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Failed to get 2D context'); ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, tileW, tileH); ctx.drawImage(img as any, 0, 0, wImg, hImg, drawXScaled, drawYScaled, wScaled, hScaled); @@ -380,7 +400,8 @@ export async function composeTileUnified( 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 }); + 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); @@ -388,7 +409,8 @@ export async function composeTileUnified( } const canvas = createCanvas(w * scale, h * scale) as any; - const ctx = canvas.getContext('2d', { willReadFrequently: true })!; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Failed to get 2D context'); ctx.putImageData(scaledBaseImageData, 0, 0); for (const ovd of overlayDatas) { @@ -397,7 +419,8 @@ export async function composeTileUnified( const th = ovd.imageData.height; if (!tw || !th) continue; const temp = createCanvas(tw, th) as any; - const tctx = temp.getContext('2d', { willReadFrequently: true })!; + const tctx = temp.getContext('2d', { willReadFrequently: true }); + if (!tctx) throw new Error('Failed to get 2D context'); tctx.putImageData(ovd.imageData, 0, 0); ctx.drawImage(temp, ovd.dx, ovd.dy); } @@ -406,13 +429,15 @@ export async function composeTileUnified( 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', { willReadFrequently: true }); + if (!ctx) throw new Error('Failed to get 2D context'); 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 })!; + const tctx = temp.getContext('2d', { willReadFrequently: true }); + if (!tctx) throw new Error('Failed to get 2D context'); tctx.putImageData(ovd.imageData, 0, 0); ctx.drawImage(temp, ovd.dx, ovd.dy); } @@ -423,7 +448,8 @@ export async function composeTileUnified( 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 })!; + const tctx = temp.getContext('2d', { willReadFrequently: true }); + if (!tctx) throw new Error('Failed to get 2D context'); tctx.putImageData(ovd.imageData, 0, 0); ctx.drawImage(temp, ovd.dx, ovd.dy); } diff --git a/src/main.ts b/src/main.ts index 933d0f3..2e6ff1d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,13 +3,11 @@ import { bootstrapApp } from "./app"; -(function () { - "use strict"; +(() => { window.onload = () => { bootstrapApp().catch((e) => console.error("Overlay Pro bootstrap failed", e), ); }; })(); -export {}; diff --git a/src/meta.js b/src/meta.js index 3a6fda0..9bf094a 100644 --- a/src/meta.js +++ b/src/meta.js @@ -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/* diff --git a/src/ui/panel.ts b/src/ui/panel.ts index fd85b5d..005566f 100644 --- a/src/ui/panel.ts +++ b/src/ui/panel.ts @@ -12,7 +12,13 @@ import { EV_ANCHOR_SET, EV_AUTOCAP_CHANGED } from '../core/events'; let panelEl: HTMLDivElement | null = null; -function $(id: string) { return document.getElementById(id)!; } +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) { + throw new Error(`Element with id "${id}" not found.`); + } + return el; +} export function createUI() { if (document.getElementById('overlay-pro-panel')) return; @@ -23,8 +29,8 @@ 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'; + 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`; panel.innerHTML = `
@@ -55,7 +61,7 @@ export function createUI() {
-
+
@@ -177,7 +183,7 @@ function rebuildOverlayListUI() { 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 = ` @@ -221,7 +227,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 +237,30 @@ 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 ImportedOverlay = { + name?: string; + imageUrl?: string; + pixelUrl?: string | null; + offsetX?: number; + offsetY?: number; + opacity?: number; +}; + 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 obj: unknown; + try { obj = JSON.parse(jsonText) as unknown; } catch { alert('Invalid JSON'); return; } + const arr: ImportedOverlay[] = Array.isArray(obj) ? (obj as ImportedOverlay[]) : [obj as ImportedOverlay]; 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; + const offsetX = Number.isFinite(item?.offsetX) ? (item?.offsetX as number) : 0; + const offsetY = Number.isFinite(item?.offsetY) ? (item?.offsetY as number) : 0; + const opacity = Number.isFinite(item?.opacity) ? (item?.opacity as number) : 0.7; if (!imageUrl) { failed++; continue; } try { const base64 = await urlToDataURL(imageUrl); @@ -253,7 +269,7 @@ async function importOverlayFromJSON(jsonText: string) { } catch (e) { console.error('Import failed for', imageUrl, e); failed++; } } if (imported > 0) { - config.activeOverlayId = config.overlays[config.overlays.length - 1].id; + config.activeOverlayId = config.overlays[config.overlays.length - 1]?.id || null; await saveConfig(['overlays', 'activeOverlayId']); clearOverlayCache(); ensureHook(); updateUI(); } alert(`Import finished. Imported: ${imported}${failed ? `, Failed: ${failed}` : ''}`); @@ -268,7 +284,7 @@ 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')); } @@ -322,7 +338,7 @@ function addEventListeners(panel: HTMLDivElement) { 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 file = e.target.files?.[0]; e.target.value=''; if (!file) return; const ov = getActiveOverlay(); if (!ov) return; if (ov.imageBase64) { alert('This overlay already has an image. Create a new overlay to change the image.'); return; } try { await setOverlayImageFromFile(ov, file); } catch (err) { console.error(err); alert('Failed to load local image.'); } @@ -330,7 +346,7 @@ function addEventListeners(panel: HTMLDivElement) { ['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 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.'); } @@ -349,7 +365,7 @@ function addEventListeners(panel: HTMLDivElement) { $('op-opacity-slider').addEventListener('input', (e: any) => { const ov = getActiveOverlay(); if (!ov) return; ov.opacity = parseFloat(e.target.value); - $('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%'; + $('op-opacity-value').textContent = `${Math.round(ov.opacity * 100)}%`; }); $('op-opacity-slider').addEventListener('change', async () => { await saveConfig(['overlays']); clearOverlayCache(); }); @@ -396,8 +412,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,12 +436,12 @@ 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() { +export function updateEditorUI(): void { const editorSect = $('op-editor-section'); const editorBody = $('op-editor-body'); const ov = getActiveOverlay(); @@ -454,12 +470,11 @@ function updateEditorUI() { 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}`; @@ -482,16 +497,18 @@ export function updateUI() { toggle.title = collapsed ? 'Expand' : 'Collapse'; // --- Mode Tabs --- - panelEl.querySelectorAll('.op-tab-btn').forEach(btn => { - const mode = btn.getAttribute('data-mode'); - let isActive = false; - if (mode === 'above' && (config.overlayMode === 'above' || config.overlayMode === 'behind')) { + if (panelEl) { + panelEl.querySelectorAll('.op-tab-btn').forEach(btn => { + const mode = btn.getAttribute('data-mode'); + let isActive = false; + if (mode === 'above' && (config.overlayMode === 'above' || config.overlayMode === 'behind')) { isActive = true; - } else { + } else { isActive = mode === config.overlayMode; - } - btn.classList.toggle('active', isActive); - }); + } + btn.classList.toggle('active', isActive); + }); + } // --- Mode Settings --- const fullOverlaySettings = $('op-mode-settings').querySelector('[data-setting="above"]') as HTMLDivElement; @@ -501,9 +518,9 @@ export function updateUI() { 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) { + ( $('op-opacity-slider') as HTMLInputElement ).value = String(ov.opacity); + $('op-opacity-value').textContent = `${Math.round(ov.opacity * 100)}%`; } } else if (config.overlayMode === 'minify') { fullOverlaySettings.classList.remove('active'); @@ -515,31 +532,30 @@ export function updateUI() { ($('op-style-dots') as HTMLInputElement).checked = config.minifyStyle === 'dots'; ($('op-style-symbols') as HTMLInputElement).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.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.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); + placeLabel?.classList.toggle('op-danger-text', !!config.autoCapturePixelUrl); - const positioningBody = $('op-positioning-body'); + const positioningBody: HTMLElement = $('op-positioning-body'); const positioningCz = $('op-collapse-positioning'); - if(positioningBody) positioningBody.style.display = config.collapsePositioning ? 'none' : 'block'; + positioningBody.style.display = config.collapsePositioning ? 'none' : 'block'; if (positioningCz) positioningCz.textContent = config.collapsePositioning ? '▸' : '▾'; const listWrap = $('op-list-wrap'); @@ -548,11 +564,11 @@ export function updateUI() { if (listCz) listCz.textContent = config.collapseList ? '▸' : '▾'; rebuildOverlayListUI(); - updateEditorUI(); + updateEditorUI(); // <- now exported, so noUnusedLocals won't flag it const exportBtn = $('op-export-overlay') as HTMLButtonElement; const ov = getActiveOverlay(); - const canExport = !!(ov && ov.imageUrl && !ov.isLocal); + const canExport = !!(ov?.imageUrl && !ov?.isLocal); exportBtn.disabled = !canExport; exportBtn.title = canExport ? 'Export active overlay JSON' : 'Export disabled for local images'; } \ No newline at end of file diff --git a/src/ui/rsModal.ts b/src/ui/rsModal.ts index 0d64eb4..3a9549b 100644 --- a/src/ui/rsModal.ts +++ b/src/ui/rsModal.ts @@ -1,6 +1,6 @@ /// 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,14 @@ 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 = refs.preview.getContext('2d', { willReadFrequently: true }); + if (!ctxPrev) throw new Error('Failed to get 2d context for preview canvas.'); + const ctxSimOrig = refs.simOrig.getContext('2d', { willReadFrequently: true }); + if (!ctxSimOrig) throw new Error('Failed to get 2d context for simOrig canvas.'); + const ctxSimNew = refs.simNew.getContext('2d', { willReadFrequently: true }); + if (!ctxSimNew) throw new Error('Failed to get 2d context for simNew canvas.'); + const ctxRes = refs.resCanvas.getContext('2d', { willReadFrequently: true }); + if (!ctxRes) throw new Error('Failed to get 2d context for resCanvas.'); rs = { ...refs, @@ -398,8 +402,7 @@ export function buildRSModal() { } function syncAdvancedMeta() { - const { cols, rows } = sampleDims(); - const limit = (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM); + sampleDims(); if (rs!.mode === 'advanced') { rs!.applyBtn.disabled = !rs!.calcReady; } else { From 6653fc66745541eccaf157422a23b0b717fbf4e8 Mon Sep 17 00:00:00 2001 From: Decrypt Date: Fri, 22 Aug 2025 16:06:12 +0200 Subject: [PATCH 02/12] fixed non null assertion + chains removed some non null assetions + changed to optional chains --- src/ui/panel.ts | 300 +++++++++++++---------- src/ui/rsModal.ts | 591 ++++++++++++++++++++++++---------------------- 2 files changed, 478 insertions(+), 413 deletions(-) diff --git a/src/ui/panel.ts b/src/ui/panel.ts index 005566f..d155f2e 100644 --- a/src/ui/panel.ts +++ b/src/ui/panel.ts @@ -12,13 +12,7 @@ import { EV_ANCHOR_SET, EV_AUTOCAP_CHANGED } from '../core/events'; let panelEl: HTMLDivElement | null = null; -function $(id: string): HTMLElement { - const el = document.getElementById(id); - if (!el) { - throw new Error(`Element with id "${id}" not found.`); - } - return el; -} +function $(id: string): HTMLElement | null { return document.getElementById(id); } export function createUI() { if (document.getElementById('overlay-pro-panel')) return; @@ -29,8 +23,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 = `
@@ -179,7 +175,8 @@ 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'); @@ -213,7 +210,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']); @@ -240,7 +237,7 @@ async function setOverlayImageFromFile(ov: any, file: File) { showToast('Local image loaded. Placement mode ON -- click once to set anchor.'); } -type ImportedOverlay = { +type ImportOverlay = { name?: string; imageUrl?: string; pixelUrl?: string | null; @@ -249,18 +246,31 @@ type ImportedOverlay = { opacity?: number; }; +function isImportOverlay(val: unknown): val is ImportOverlay { + return typeof val === 'object' && val !== null; +} + async function importOverlayFromJSON(jsonText: string) { - let obj: unknown; - try { obj = JSON.parse(jsonText) as unknown; } catch { alert('Invalid JSON'); return; } - const arr: ImportedOverlay[] = Array.isArray(obj) ? (obj as ImportedOverlay[]) : [obj as ImportedOverlay]; + 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 as number) : 0; - const offsetY = Number.isFinite(item?.offsetY) ? (item?.offsetY as number) : 0; - const opacity = Number.isFinite(item?.opacity) ? (item?.opacity as number) : 0.7; + 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); @@ -269,7 +279,7 @@ async function importOverlayFromJSON(jsonText: string) { } catch (e) { console.error('Import failed for', imageUrl, e); failed++; } } if (imported > 0) { - config.activeOverlayId = config.overlays[config.overlays.length - 1]?.id || null; + config.activeOverlayId = config.overlays[config.overlays.length - 1].id; await saveConfig(['overlays', 'activeOverlayId']); clearOverlayCache(); ensureHook(); updateUI(); } alert(`Import finished. Imported: ${imported}${failed ? `, Failed: ${failed}` : ''}`); @@ -289,9 +299,9 @@ function copyText(text: string) { } 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', () => { @@ -306,21 +316,27 @@ function addEventListeners(panel: HTMLDivElement) { updateUI(); }); }); - $('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(); } }); - $('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(); } }); - $('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}".`); @@ -328,48 +344,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?.[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?.[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'); @@ -380,7 +404,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); }); @@ -395,7 +419,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; @@ -413,7 +437,7 @@ function enableDrag(panel: HTMLDivElement) { 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.top = `${clamp(startTop + dy, 8, maxTop)}px`; moved = true; }; const onPointerUp = (e: PointerEvent) => { @@ -441,31 +465,36 @@ function enableDrag(panel: HTMLDivElement) { }); } -export function updateEditorUI(): void { - const editorSect = $('op-editor-section'); - const editorBody = $('op-editor-body'); +function updateEditorUI() { + 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; @@ -489,86 +518,97 @@ 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 --- - if (panelEl) { - panelEl.querySelectorAll('.op-tab-btn').forEach(btn => { - const mode = btn.getAttribute('data-mode'); - let isActive = false; - if (mode === 'above' && (config.overlayMode === 'above' || config.overlayMode === 'behind')) { + panelEl.querySelectorAll('.op-tab-btn').forEach(btn => { + const mode = btn.getAttribute('data-mode'); + let isActive = false; + if (mode === 'above' && (config.overlayMode === 'above' || config.overlayMode === 'behind')) { isActive = true; - } else { + } 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)}%`; + 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 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 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') 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); - 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: HTMLElement = $('op-positioning-body'); + const positioningBody = $('op-positioning-body'); const positioningCz = $('op-collapse-positioning'); - 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(); // <- now exported, so noUnusedLocals won't flag it + updateEditorUI(); - const exportBtn = $('op-export-overlay') as HTMLButtonElement; + const exportBtn = $('op-export-overlay') as HTMLButtonElement | null; const ov = getActiveOverlay(); const canExport = !!(ov?.imageUrl && !ov?.isLocal); - exportBtn.disabled = !canExport; - exportBtn.title = canExport ? 'Export active overlay JSON' : 'Export disabled for local images'; + if (exportBtn) { + exportBtn.disabled = !canExport; + exportBtn.title = canExport ? 'Export active overlay JSON' : 'Export disabled for local images'; + } } \ No newline at end of file diff --git a/src/ui/rsModal.ts b/src/ui/rsModal.ts index 3a9549b..8434438 100644 --- a/src/ui/rsModal.ts +++ b/src/ui/rsModal.ts @@ -291,14 +291,26 @@ export function buildRSModal() { closeBtn: modal.querySelector('#op-rs-close') as HTMLButtonElement, }; - const ctxPrev = refs.preview.getContext('2d', { willReadFrequently: true }); - if (!ctxPrev) throw new Error('Failed to get 2d context for preview canvas.'); - const ctxSimOrig = refs.simOrig.getContext('2d', { willReadFrequently: true }); - if (!ctxSimOrig) throw new Error('Failed to get 2d context for simOrig canvas.'); - const ctxSimNew = refs.simNew.getContext('2d', { willReadFrequently: true }); - if (!ctxSimNew) throw new Error('Failed to get 2d context for simNew canvas.'); - const ctxRes = refs.resCanvas.getContext('2d', { willReadFrequently: true }); - if (!ctxRes) throw new Error('Failed to get 2d context for resCanvas.'); + 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, @@ -325,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;i0&&H>0&&W= 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); } @@ -460,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(); @@ -492,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(); } @@ -542,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; @@ -688,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.`); @@ -750,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); @@ -781,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) { @@ -834,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(); @@ -906,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; @@ -917,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; @@ -951,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); From 30e98ecffc67d94460af103e3e8e50d47724760c Mon Sep 17 00:00:00 2001 From: Decrypt Date: Fri, 22 Aug 2025 16:19:02 +0200 Subject: [PATCH 03/12] fix forbidden non null assertions also removed unused --- src/ui/ccModal.ts | 199 +++++++++++++++++++++++++++------------------- 1 file changed, 116 insertions(+), 83 deletions(-) diff --git a/src/ui/ccModal.ts b/src/ui/ccModal.ts index fb3f1ec..e74ab3f 100644 --- a/src/ui/ccModal.ts +++ b/src/ui/ccModal.ts @@ -1,6 +1,5 @@ /// 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 = {}; 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`; } \ No newline at end of file From 5b12b51cf4a49de43f88dc184f78122be7f41298 Mon Sep 17 00:00:00 2001 From: Decrypt Date: Fri, 22 Aug 2025 16:23:12 +0200 Subject: [PATCH 04/12] Fetch hook overhead Every network request on the page was being processed by the overlay hook, even non-wplace requests --- src/core/hook.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/hook.ts b/src/core/hook.ts index 5703aec..fca86ec 100644 --- a/src/core/hook.ts +++ b/src/core/hook.ts @@ -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); From 00c8bd5aa2faaca097dcb2318119cd01f28b5233 Mon Sep 17 00:00:00 2001 From: Decrypt Date: Fri, 22 Aug 2025 16:36:11 +0200 Subject: [PATCH 05/12] inject symbols into palette --- src/app.ts | 2 + src/core/palette-inject.ts | 195 +++++++++++++++++++++++++++++++++++++ src/ui/panel.ts | 22 ++++- 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 src/core/palette-inject.ts diff --git a/src/app.ts b/src/app.ts index 0ec11dd..5ed4312 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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.'); } \ No newline at end of file diff --git a/src/core/palette-inject.ts b/src/core/palette-inject.ts new file mode 100644 index 0000000..04118a5 --- /dev/null +++ b/src/core/palette-inject.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/ui/panel.ts b/src/ui/panel.ts index d155f2e..41ac078 100644 --- a/src/ui/panel.ts +++ b/src/ui/panel.ts @@ -9,6 +9,7 @@ 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; @@ -314,14 +315,31 @@ function addEventListeners(panel: HTMLDivElement) { saveConfig(['overlayMode']); ensureHook(); updateUI(); + updatePaletteSymbols(); }); }); const styleDotsEl = $('op-style-dots') as HTMLInputElement | null; - styleDotsEl?.addEventListener('change', () => { if (styleDotsEl.checked) { config.minifyStyle = 'dots'; saveConfig(['minifyStyle']); clearOverlayCache(); ensureHook(); } }); + styleDotsEl?.addEventListener('change', () => { + if (styleDotsEl.checked) { + config.minifyStyle = 'dots'; + saveConfig(['minifyStyle']); + clearOverlayCache(); + ensureHook(); + updatePaletteSymbols(); + } + }); const styleSymbolsEl = $('op-style-symbols') as HTMLInputElement | null; - styleSymbolsEl?.addEventListener('change', () => { if (styleSymbolsEl.checked) { config.minifyStyle = 'symbols'; saveConfig(['minifyStyle']); clearOverlayCache(); ensureHook(); } }); + styleSymbolsEl?.addEventListener('change', () => { + if (styleSymbolsEl.checked) { + config.minifyStyle = 'symbols'; + saveConfig(['minifyStyle']); + clearOverlayCache(); + ensureHook(); + updatePaletteSymbols(); + } + }); $('op-autocap-toggle')?.addEventListener('click', () => { config.autoCapturePixelUrl = !config.autoCapturePixelUrl; saveConfig(['autoCapturePixelUrl']); ensureHook(); updateUI(); }); From 689a6111639e078a9da3143fa5582ee04a5ecd9a Mon Sep 17 00:00:00 2001 From: Decrypt Date: Fri, 22 Aug 2025 17:10:40 +0200 Subject: [PATCH 06/12] Update events.ts --- src/core/events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/events.ts b/src/core/events.ts index 8ba6c94..dfdd2dd 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -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 { } } \ No newline at end of file From 568a29f1264548fcc36227cdd37ec8635af44ce9 Mon Sep 17 00:00:00 2001 From: Decrypt Date: Fri, 22 Aug 2025 17:19:30 +0200 Subject: [PATCH 07/12] Update overlay.ts --- src/core/overlay.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/overlay.ts b/src/core/overlay.ts index 98ff907..19dade7 100644 --- a/src/core/overlay.ts +++ b/src/core/overlay.ts @@ -43,9 +43,9 @@ function findClosestColorIndex(r: number, g: number, b: number) { 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; @@ -147,7 +147,7 @@ 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('|'); } From ce739a240018e301f1fc064182b667f1609aee3b Mon Sep 17 00:00:00 2001 From: Decrypt Date: Sat, 23 Aug 2025 18:40:41 +0200 Subject: [PATCH 08/12] Add cache for overlay ImageData --- src/core/cache.ts | 2 ++ src/core/overlay.ts | 28 ++++++++++++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/core/cache.ts b/src/core/cache.ts index 21e2dcb..06119b1 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -28,11 +28,13 @@ export const imageDecodeCache = new LRUCache(64); export const paletteDetectionCache = new LRUCache(200); export const baseMinifyCache = new LRUCache(100); export const tooLargeOverlays = new Set(); +export const overlayImageDataCache = new LRUCache(100); export function clearOverlayCache() { overlayCache.clear(); imageDecodeCache.clear(); paletteDetectionCache.clear(); baseMinifyCache.clear(); + overlayImageDataCache.clear(); tooLargeOverlays.clear(); } \ No newline at end of file diff --git a/src/core/overlay.ts b/src/core/overlay.ts index 19dade7..48cec7e 100644 --- a/src/core/overlay.ts +++ b/src/core/overlay.ts @@ -1,6 +1,6 @@ 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 } 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'; @@ -152,6 +152,22 @@ export function overlaySignature(ov: { return [imgKey, ov.pixelUrl || 'null', ov.offsetX, ov.offsetY, ov.opacity, perfectFlag].join('|'); } +// Add cache for overlay ImageData +// Only change: get overlay ImageData once per overlay +async function getCachedOverlayImageData(img: HTMLImageElement, overlayId: string): Promise { + 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 }); + 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: { id: string, name: string, enabled: boolean, @@ -222,6 +238,7 @@ export async function buildOverlayDataForChunkUnified( return result; } else { if (config.minifyStyle === 'symbols') { + // Use cached ImageData instead of reading every time const scale = MINIFY_SCALE_SYMBOL; const tileW = TILE_SIZE * scale; const tileH = TILE_SIZE * scale; @@ -233,13 +250,8 @@ export async function buildOverlayDataForChunkUnified( const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled); if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; } - // Get original image data once - const sourceCanvas = createCanvas(wImg, hImg) as any; - const sourceCtx = sourceCanvas.getContext('2d', { willReadFrequently: true }); - if (!sourceCtx) throw new Error('Failed to get 2D context'); - sourceCtx.imageSmoothingEnabled = false; - sourceCtx.drawImage(img as any, 0, 0); - const originalImageData = sourceCtx.getImageData(0, 0, wImg, hImg); + // Get cached ImageData instead of reading every time + const originalImageData = await getCachedOverlayImageData(img, ov.id); const srcData = originalImageData.data; // Create output canvas for just the intersection area From ed4b2bdc74c8e1d1e998719d9a4e38049cfbc792 Mon Sep 17 00:00:00 2001 From: Decrypt Date: Wed, 27 Aug 2025 20:12:45 +0200 Subject: [PATCH 09/12] Update overlay.ts --- src/core/overlay.ts | 333 ++++++++++++++++++-------------------------- 1 file changed, 136 insertions(+), 197 deletions(-) diff --git a/src/core/overlay.ts b/src/core/overlay.ts index 48cec7e..fd3ff14 100644 --- a/src/core/overlay.ts +++ b/src/core/overlay.ts @@ -14,6 +14,11 @@ const LUT_SIZE = 32; // 32x32x32 = 32KB const LUT_SHIFT = 8 - Math.log2(LUT_SIZE); // 3 for 32x32x32 const colorLUT = new Uint8Array(LUT_SIZE * LUT_SIZE * LUT_SIZE); +// --- OPTIMIZATION: Cache for pre-rendered symbols --- +// This avoids re-drawing the 5x5 symbol pixel-by-pixel every single time. +// We render each symbol once and then use the fast ctx.drawImage(). +const symbolCache = new Map(); + function buildColorLUT() { for (let r = 0; r < LUT_SIZE; r++) { for (let g = 0; g < LUT_SIZE; g++) { @@ -37,6 +42,41 @@ function findColorIndexLUT(r: number, g: number, b: number): number { buildColorLUT(); +// --- OPTIMIZATION: Pre-renders a symbol for a given color index --- +function getPreRenderedSymbol(colorIndex: number): OffscreenCanvas | HTMLCanvasElement | null { + if (symbolCache.has(colorIndex)) { + return symbolCache.get(colorIndex)!; + } + + if (colorIndex >= SYMBOL_TILES.length) return null; + + const scale = MINIFY_SCALE_SYMBOL; + const canvas = createCanvas(scale, scale); + // Type assertion is needed because TypeScript can't guarantee the context has drawing methods like 'fillStyle'. + 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; +} + + function findClosestColorIndex(r: number, g: number, b: number) { let minDistance = Infinity; let index = 0; @@ -105,7 +145,7 @@ function isPalettePerfectImage(img: HTMLImageElement): boolean { 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); @@ -152,15 +192,13 @@ export function overlaySignature(ov: { return [imgKey, ov.pixelUrl || 'null', ov.offsetX, ov.offsetY, ov.opacity, perfectFlag].join('|'); } -// Add cache for overlay ImageData -// Only change: get overlay ImageData once per overlay async function getCachedOverlayImageData(img: HTMLImageElement, overlayId: string): Promise { 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 }); + 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); @@ -197,197 +235,101 @@ 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') { 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 }); + 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, 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; - } - } + // Draw the relevant part of the overlay image + ctx.drawImage(img as any, isect.x - drawX, isect.y - drawY, isect.w, isect.h, 0, 0, isect.w, isect.h); + + // Apply opacity effect + 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 { + + } else { // Minify modes + 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 isect = rectIntersect(0, 0, tileScaledW, tileScaledH, drawXScaled, drawYScaled, wImgScaled, hImgScaled); + if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; } + + const originalImageData = await getCachedOverlayImageData(img, ov.id); + const srcData = originalImageData.data; + + 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 pixels to iterate over + const startX = Math.max(0, Math.floor((isect.x - drawXScaled) / scale)); + const startY = Math.max(0, Math.floor((isect.y - drawYScaled) / scale)); + const endX = Math.min(wImg, Math.ceil((isect.x + isect.w - drawXScaled) / scale)); + const endY = Math.min(hImg, Math.ceil((isect.y + isect.h - drawYScaled) / scale)); + if (config.minifyStyle === 'symbols') { - // Use cached ImageData instead of reading every time - 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; + for (let y = startY; y < endY; y++) { + for (let x = startX; x < endX; x++) { + const srcIdx = (y * wImg + x) * 4; + const r = srcData[srcIdx], g = srcData[srcIdx + 1], b = srcData[srcIdx + 2], a = srcData[srcIdx + 3]; - const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled); - if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; } + if (a <= 128 || (r === 0xde && g === 0xfa && b === 0xce)) continue; - // Get cached ImageData instead of reading every time - const originalImageData = await getCachedOverlayImageData(img, ov.id); - const srcData = originalImageData.data; - - // Create output canvas for just the intersection area - const outCanvas = createCanvas(isect.w, isect.h) as any; - const outCtx = outCanvas.getContext('2d', { willReadFrequently: true }); - if (!outCtx) throw new Error('Failed to get 2D context'); - const outputImageData = outCtx.createImageData(isect.w, isect.h); - const outData = outputImageData.data; - - // Precompute symbol centering offsets - const centerX = (scale - SYMBOL_W) >> 1; - const centerY = (scale - SYMBOL_H) >> 1; - - // Convert intersection back to tile coordinates - const startTileX = Math.floor(isect.x / scale); - const startTileY = Math.floor(isect.y / scale); - const endTileX = Math.floor((isect.x + isect.w - 1) / scale); - const endTileY = Math.floor((isect.y + isect.h - 1) / scale); - - // Only iterate over tiles that intersect - for (let tileY = startTileY; tileY <= endTileY; tileY++) { - for (let tileX = startTileX; tileX <= endTileX; tileX++) { - // Convert back to original image coordinates - const imgX = tileX - drawX; - const imgY = tileY - drawY; + const colorIndex = isPalettePerfect ? (colorIndexMap.get(`${r},${g},${b}`) ?? 0) : findColorIndexLUT(r, g, b); - if (imgX >= 0 && imgX < wImg && imgY >= 0 && imgY < hImg) { - const srcIdx = (imgY * wImg + imgX) * 4; - const r = srcData[srcIdx]; - const g = srcData[srcIdx + 1]; - const b = srcData[srcIdx + 2]; - const a = srcData[srcIdx + 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]; - - // Cache palette color to avoid repeated array access - const paletteColor = ALL_COLORS[colorIndex]; - const pR = paletteColor[0]; - const pG = paletteColor[1]; - const pB = paletteColor[2]; - - // Calculate tile position in scaled coordinates - const tileXScaled = tileX * scale; - const tileYScaled = tileY * scale; - - // Draw symbol - 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 = tileXScaled + sx + centerX - isect.x; - const outY = tileYScaled + sy + centerY - isect.y; - - if (outX >= 0 && outX < isect.w && outY >= 0 && outY < isect.h) { - const outIdx = (outY * isect.w + outX) * 4; - outData[outIdx] = pR; - outData[outIdx + 1] = pG; - outData[outIdx + 2] = pB; - outData[outIdx + 3] = 255; - } - } - } - } - } + const symbolCanvas = getPreRenderedSymbol(colorIndex); + if (symbolCanvas) { + const outX = Math.round(drawX * scale) + x * scale - isect.x; + const outY = Math.round(drawY * scale) + y * scale - isect.y; + outCtx.drawImage(symbolCanvas, outX, outY); } } } - - outCtx.putImageData(outputImageData, 0, 0); - - const result = { imageData: outputImageData, dx: isect.x, dy: isect.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 center = Math.floor(scale / 2); + for (let y = startY; y < endY; y++) { + for (let x = startX; x < endX; x++) { + const srcIdx = (y * wImg + x) * 4; + const r = srcData[srcIdx], g = srcData[srcIdx + 1], b = srcData[srcIdx + 2], a = srcData[srcIdx + 3]; - 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 }); - if (!ctx) throw new Error('Failed to get 2D context'); - 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; - } + if (a <= 128 || (r === 0xde && g === 0xfa && b === 0xce)) continue; + + outCtx.fillStyle = `rgb(${r}, ${g}, ${b})`; + const outX = Math.round(drawX * scale) + x * scale + center - isect.x; + const outY = Math.round(drawY * scale) + y * scale + center - isect.y; + outCtx.fillRect(outX, outY, 1, 1); } - const result = { imageData, dx: isect.x, dy: isect.y, scaled: true, scale }; - overlayCache.set(cacheKey, result); - return result; + } } + + 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; } } @@ -412,7 +354,7 @@ export async function composeTileUnified( 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); @@ -421,52 +363,47 @@ export async function composeTileUnified( } 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 }); - if (!tctx) throw new Error('Failed to get 2D context'); + // Use a temporary canvas to draw the overlay data, which is faster + 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); } + // Standard 'above' or 'behind' modes 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 }); - if (!tctx) throw new Error('Failed to get 2D context'); - 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 }); - if (!tctx) throw new Error('Failed to get 2D context'); - 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) { @@ -477,6 +414,8 @@ export async function displayImageFromData(newOverlay: OverlayItem) { await saveConfig(); clearOverlayCache(); + // Clear symbol cache in case palette changes in the future + symbolCache.clear(); ensureHook(); const updateUI = getUpdateUI(); From 8a407d207631658bb3bd4e84279cb39a19045357 Mon Sep 17 00:00:00 2001 From: Decrypt Date: Wed, 27 Aug 2025 20:22:32 +0200 Subject: [PATCH 10/12] Update overlay.ts --- src/core/overlay.ts | 240 +++++++++++++++++++++++--------------------- 1 file changed, 124 insertions(+), 116 deletions(-) diff --git a/src/core/overlay.ts b/src/core/overlay.ts index fd3ff14..c1bc766 100644 --- a/src/core/overlay.ts +++ b/src/core/overlay.ts @@ -1,24 +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, overlayImageDataCache } 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(50); // Cache for run-length encoded overlay data + const ALL_COLORS = [...WPLACE_FREE, ...WPLACE_PAID]; const colorIndexMap = new Map(); 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); - -// --- OPTIMIZATION: Cache for pre-rendered symbols --- -// This avoids re-drawing the 5x5 symbol pixel-by-pixel every single time. -// We render each symbol once and then use the fast ctx.drawImage(). const symbolCache = new Map(); +// (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++) { @@ -32,51 +34,12 @@ 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(); - -// --- OPTIMIZATION: Pre-renders a symbol for a given color index --- -function getPreRenderedSymbol(colorIndex: number): OffscreenCanvas | HTMLCanvasElement | null { - if (symbolCache.has(colorIndex)) { - return symbolCache.get(colorIndex)!; - } - - if (colorIndex >= SYMBOL_TILES.length) return null; - - const scale = MINIFY_SCALE_SYMBOL; - const canvas = createCanvas(scale, scale); - // Type assertion is needed because TypeScript can't guarantee the context has drawing methods like 'fillStyle'. - 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; -} - - function findClosestColorIndex(r: number, g: number, b: number) { let minDistance = Infinity; let index = 0; @@ -94,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 { @@ -110,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); @@ -120,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); @@ -131,45 +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 }) 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; @@ -179,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, @@ -191,12 +202,10 @@ export function overlaySignature(ov: { 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 { 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'); @@ -206,6 +215,7 @@ async function getCachedOverlayImageData(img: HTMLImageElement, overlayId: strin return imageData; } + export async function buildOverlayDataForChunkUnified( ov: { id: string, name: string, enabled: boolean, @@ -242,31 +252,25 @@ export async function buildOverlayDataForChunkUnified( if (cached !== undefined) return cached; 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(isect.w, isect.h) as any; const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; if (!ctx) throw new Error('Failed to get 2D context'); - - // Draw the relevant part of the overlay image ctx.drawImage(img as any, isect.x - drawX, isect.y - drawY, isect.w, isect.h, 0, 0, isect.w, isect.h); - - // Apply opacity effect 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 { // Minify modes + } 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; @@ -278,52 +282,66 @@ export async function buildOverlayDataForChunkUnified( 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 originalImageData = await getCachedOverlayImageData(img, ov.id); - const srcData = originalImageData.data; + // 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 pixels to iterate over - const startX = Math.max(0, Math.floor((isect.x - drawXScaled) / scale)); + // Calculate the range of source image rows to iterate over const startY = Math.max(0, Math.floor((isect.y - drawYScaled) / scale)); - const endX = Math.min(wImg, Math.ceil((isect.x + isect.w - drawXScaled) / scale)); const endY = Math.min(hImg, Math.ceil((isect.y + isect.h - drawYScaled) / scale)); - if (config.minifyStyle === 'symbols') { - for (let y = startY; y < endY; y++) { - for (let x = startX; x < endX; x++) { - const srcIdx = (y * wImg + x) * 4; - const r = srcData[srcIdx], g = srcData[srcIdx + 1], b = srcData[srcIdx + 2], a = srcData[srcIdx + 3]; + for (let y = startY; y < endY; y++) { + const rowRLE = rleData[y]; + let currentX = 0; + const outY = drawYScaled + y * scale - isect.y; - if (a <= 128 || (r === 0xde && g === 0xfa && b === 0xce)) continue; + for (const run of rowRLE) { + if (run.colorIndex !== -1) { + const runStartImgX = currentX; + const runEndImgX = currentX + run.length; - const colorIndex = isPalettePerfect ? (colorIndexMap.get(`${r},${g},${b}`) ?? 0) : findColorIndexLUT(r, g, b); - - const symbolCanvas = getPreRenderedSymbol(colorIndex); - if (symbolCanvas) { - const outX = Math.round(drawX * scale) + x * scale - isect.x; - const outY = Math.round(drawY * scale) + y * scale - isect.y; - outCtx.drawImage(symbolCanvas, outX, outY); - } + // 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; } - } - } else { // 'dots' - const center = Math.floor(scale / 2); - for (let y = startY; y < endY; y++) { - for (let x = startX; x < endX; x++) { - const srcIdx = (y * wImg + x) * 4; - const r = srcData[srcIdx], g = srcData[srcIdx + 1], b = srcData[srcIdx + 2], a = srcData[srcIdx + 3]; - - if (a <= 128 || (r === 0xde && g === 0xfa && b === 0xce)) continue; - - outCtx.fillStyle = `rgb(${r}, ${g}, ${b})`; - const outX = Math.round(drawX * scale) + x * scale + center - isect.x; - const outY = Math.round(drawY * scale) + y * scale + center - isect.y; - outCtx.fillRect(outX, outY, 1, 1); - } - } } const imageData = outCtx.getImageData(0, 0, isect.w, isect.h); @@ -333,6 +351,7 @@ export async function buildOverlayDataForChunkUnified( } } +// composeTileUnified is unchanged export async function composeTileUnified( originalBlob: Blob, overlayDatas: Array<{ imageData: ImageData, dx: number, dy: number, scaled?: boolean } | null>, @@ -340,18 +359,14 @@ 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 }) as CanvasRenderingContext2D; @@ -361,15 +376,12 @@ export async function composeTileUnified( 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 }) 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; - // Use a temporary canvas to draw the overlay data, which is faster const temp = createCanvas(ovd.imageData.width, ovd.imageData.height) as any; const tctx = temp.getContext('2d') as CanvasRenderingContext2D; if (!tctx) continue; @@ -378,13 +390,10 @@ export async function composeTileUnified( } return await canvasToBlob(canvas); } - - // Standard 'above' or 'behind' modes const w = originalImage.width, h = originalImage.height; const canvas = createCanvas(w, h) as any; 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; @@ -395,7 +404,6 @@ export async function composeTileUnified( ctx.drawImage(temp, ovd.dx, ovd.dy); } }; - if (mode === 'behind') { drawOverlays(); ctx.drawImage(originalImage, 0, 0); @@ -414,7 +422,7 @@ export async function displayImageFromData(newOverlay: OverlayItem) { await saveConfig(); clearOverlayCache(); - // Clear symbol cache in case palette changes in the future + overlayRLECache.clear(); symbolCache.clear(); ensureHook(); From 99ead87e1a976314523ed615f542da931eec28bb Mon Sep 17 00:00:00 2001 From: Decrypt Date: Mon, 1 Sep 2025 17:06:25 +0200 Subject: [PATCH 11/12] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b877ba..70cefac 100644 --- a/README.md +++ b/README.md @@ -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). From c7a7028bc99f4a317e9effb571671b0cd8e5c29b Mon Sep 17 00:00:00 2001 From: Decrypt Date: Mon, 1 Sep 2025 17:07:20 +0200 Subject: [PATCH 12/12] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 70cefac..cf4d12c 100644 --- a/README.md +++ b/README.md @@ -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)