diff --git a/Extension/scripts/Auto-Image.js b/Extension/scripts/Auto-Image.js
index bd32746..9436bbf 100644
--- a/Extension/scripts/Auto-Image.js
+++ b/Extension/scripts/Auto-Image.js
@@ -1,10542 +1,10853 @@
-// ==UserScript==
-// @name WPlaceBot
-// @namespace http://tampermonkey.net/
-// @version 2025-08-08.3
-// @description Bot
-// @author Wbot
-// @match https://wplace.live/*
-// @grant none
-// @icon
-// ==/UserScript==
-localStorage.removeItem("lp");
-
-// Fallback translation function for when utils manager isn't loaded
-function getText(key, params) {
- // Try to get translation from loadedTranslations
- try {
- if (window.loadedTranslations && window.loadedTranslations[key]) {
- let text = window.loadedTranslations[key];
- if (params) {
- Object.keys(params).forEach(paramKey => {
- text = text.replace(new RegExp(`{{${paramKey}}}`, 'g'), params[paramKey]);
- });
- }
- return text;
- }
-
- // Try with state.language if available
- if (window.state && window.state.language && window.loadedTranslations && window.loadedTranslations[window.state.language] && window.loadedTranslations[window.state.language][key]) {
- let text = window.loadedTranslations[window.state.language][key];
- if (params) {
- Object.keys(params).forEach(paramKey => {
- text = text.replace(new RegExp(`{{${paramKey}}}`, 'g'), params[paramKey]);
- });
- }
- return text;
- }
- } catch (error) {
- console.warn('Error in getText fallback:', error);
- }
-
- return key; // Fallback to key if no translation found
-}
-
-; (async () => {
- // Prevent multiple instances of this script from running
- if (window.WPLACE_AUTO_IMAGE_LOADED) {
- console.log('%cā ļø Auto-Image script already loaded, skipping duplicate execution', 'color: #ff9800; font-weight: bold;');
- return;
- }
- window.WPLACE_AUTO_IMAGE_LOADED = true;
-
- console.log('%cš WPlace AutoBot Auto-Image Starting...', 'color: #00ff41; font-weight: bold; font-size: 16px;');
-
- const buyTypes = [
- 'none',
- 'max_charges',
- 'paint_charges'
- ];
-
- // CONFIGURATION CONSTANTS
- const CONFIG = {
- COOLDOWN_DEFAULT: 31000,
- TRANSPARENCY_THRESHOLD: 100,
- WHITE_THRESHOLD: 250,
- LOG_INTERVAL: 10,
- PAINTING_SPEED: {
- MIN: 1, // Minimum 1 pixel batch size
- MAX: 1000, // Maximum 1000 pixels batch size
- DEFAULT: 5, // Default 5 pixels batch size
- },
- BATCH_MODE: 'normal', // "normal" or "random" - default to normal
- RANDOM_BATCH_RANGE: {
- MIN: 3, // Random range minimum
- MAX: 20, // Random range maximum
- },
- PAINTING_ORDER: 'sequential', // "sequential" or "color-by-color" - default to sequential
- PAINTING_SPEED_ENABLED: false, // Off by default
- AUTO_CAPTCHA_ENABLED: true, // Turnstile generator enabled by default
- TOKEN_SOURCE: 'generator', // "generator", "manual", or "hybrid" - default to generator
- COOLDOWN_CHARGE_THRESHOLD: 1, // Default wait threshold
- // Desktop Notifications (defaults)
- NOTIFICATIONS: {
- ENABLED: false,
- ON_CHARGES_REACHED: true,
- ONLY_WHEN_UNFOCUSED: true,
- REPEAT_MINUTES: 5, // repeat reminder while threshold condition holds
- },
- OVERLAY: {
- OPACITY_DEFAULT: 0.6,
- BLUE_MARBLE_DEFAULT: false,
- ditheringEnabled: true,
- }, // --- START: Color data from colour-converter.js ---
- // New color structure with proper ID mapping
- COLOR_MAP: {
- 0: { id: 1, name: 'Black', rgb: { r: 0, g: 0, b: 0 } },
- 1: { id: 2, name: 'Dark Gray', rgb: { r: 60, g: 60, b: 60 } },
- 2: { id: 3, name: 'Gray', rgb: { r: 120, g: 120, b: 120 } },
- 3: { id: 4, name: 'Light Gray', rgb: { r: 210, g: 210, b: 210 } },
- 4: { id: 5, name: 'White', rgb: { r: 255, g: 255, b: 255 } },
- 5: { id: 6, name: 'Deep Red', rgb: { r: 96, g: 0, b: 24 } },
- 6: { id: 7, name: 'Red', rgb: { r: 237, g: 28, b: 36 } },
- 7: { id: 8, name: 'Orange', rgb: { r: 255, g: 127, b: 39 } },
- 8: { id: 9, name: 'Gold', rgb: { r: 246, g: 170, b: 9 } },
- 9: { id: 10, name: 'Yellow', rgb: { r: 249, g: 221, b: 59 } },
- 10: { id: 11, name: 'Light Yellow', rgb: { r: 255, g: 250, b: 188 } },
- 11: { id: 12, name: 'Dark Green', rgb: { r: 14, g: 185, b: 104 } },
- 12: { id: 13, name: 'Green', rgb: { r: 19, g: 230, b: 123 } },
- 13: { id: 14, name: 'Light Green', rgb: { r: 135, g: 255, b: 94 } },
- 14: { id: 15, name: 'Dark Teal', rgb: { r: 12, g: 129, b: 110 } },
- 15: { id: 16, name: 'Teal', rgb: { r: 16, g: 174, b: 166 } },
- 16: { id: 17, name: 'Light Teal', rgb: { r: 19, g: 225, b: 190 } },
- 17: { id: 20, name: 'Cyan', rgb: { r: 96, g: 247, b: 242 } },
- 18: { id: 44, name: 'Light Cyan', rgb: { r: 187, g: 250, b: 242 } },
- 19: { id: 18, name: 'Dark Blue', rgb: { r: 40, g: 80, b: 158 } },
- 20: { id: 19, name: 'Blue', rgb: { r: 64, g: 147, b: 228 } },
- 21: { id: 21, name: 'Indigo', rgb: { r: 107, g: 80, b: 246 } },
- 22: { id: 22, name: 'Light Indigo', rgb: { r: 153, g: 177, b: 251 } },
- 23: { id: 23, name: 'Dark Purple', rgb: { r: 120, g: 12, b: 153 } },
- 24: { id: 24, name: 'Purple', rgb: { r: 170, g: 56, b: 185 } },
- 25: { id: 25, name: 'Light Purple', rgb: { r: 224, g: 159, b: 249 } },
- 26: { id: 26, name: 'Dark Pink', rgb: { r: 203, g: 0, b: 122 } },
- 27: { id: 27, name: 'Pink', rgb: { r: 236, g: 31, b: 128 } },
- 28: { id: 28, name: 'Light Pink', rgb: { r: 243, g: 141, b: 169 } },
- 29: { id: 29, name: 'Dark Brown', rgb: { r: 104, g: 70, b: 52 } },
- 30: { id: 30, name: 'Brown', rgb: { r: 149, g: 104, b: 42 } },
- 31: { id: 31, name: 'Beige', rgb: { r: 248, g: 178, b: 119 } },
- 32: { id: 52, name: 'Light Beige', rgb: { r: 255, g: 197, b: 165 } },
- 33: { id: 32, name: 'Medium Gray', rgb: { r: 170, g: 170, b: 170 } },
- 34: { id: 33, name: 'Dark Red', rgb: { r: 165, g: 14, b: 30 } },
- 35: { id: 34, name: 'Light Red', rgb: { r: 250, g: 128, b: 114 } },
- 36: { id: 35, name: 'Dark Orange', rgb: { r: 228, g: 92, b: 26 } },
- 37: { id: 37, name: 'Dark Goldenrod', rgb: { r: 156, g: 132, b: 49 } },
- 38: { id: 38, name: 'Goldenrod', rgb: { r: 197, g: 173, b: 49 } },
- 39: { id: 39, name: 'Light Goldenrod', rgb: { r: 232, g: 212, b: 95 } },
- 40: { id: 40, name: 'Dark Olive', rgb: { r: 74, g: 107, b: 58 } },
- 41: { id: 41, name: 'Olive', rgb: { r: 90, g: 148, b: 74 } },
- 42: { id: 42, name: 'Light Olive', rgb: { r: 132, g: 197, b: 115 } },
- 43: { id: 43, name: 'Dark Cyan', rgb: { r: 15, g: 121, b: 159 } },
- 44: { id: 45, name: 'Light Blue', rgb: { r: 125, g: 199, b: 255 } },
- 45: { id: 46, name: 'Dark Indigo', rgb: { r: 77, g: 49, b: 184 } },
- 46: { id: 47, name: 'Dark Slate Blue', rgb: { r: 74, g: 66, b: 132 } },
- 47: { id: 48, name: 'Slate Blue', rgb: { r: 122, g: 113, b: 196 } },
- 48: { id: 49, name: 'Light Slate Blue', rgb: { r: 181, g: 174, b: 241 } },
- 49: { id: 53, name: 'Dark Peach', rgb: { r: 155, g: 82, b: 73 } },
- 50: { id: 54, name: 'Peach', rgb: { r: 209, g: 128, b: 120 } },
- 51: { id: 55, name: 'Light Peach', rgb: { r: 250, g: 182, b: 164 } },
- 52: { id: 50, name: 'Light Brown', rgb: { r: 219, g: 164, b: 99 } },
- 53: { id: 56, name: 'Dark Tan', rgb: { r: 123, g: 99, b: 82 } },
- 54: { id: 57, name: 'Tan', rgb: { r: 156, g: 132, b: 107 } },
- 55: { id: 36, name: 'Light Tan', rgb: { r: 214, g: 181, b: 148 } },
- 56: { id: 51, name: 'Dark Beige', rgb: { r: 209, g: 128, b: 81 } },
- 57: { id: 61, name: 'Dark Stone', rgb: { r: 109, g: 100, b: 63 } },
- 58: { id: 62, name: 'Stone', rgb: { r: 148, g: 140, b: 107 } },
- 59: { id: 63, name: 'Light Stone', rgb: { r: 205, g: 197, b: 158 } },
- 60: { id: 58, name: 'Dark Slate', rgb: { r: 51, g: 57, b: 65 } },
- 61: { id: 59, name: 'Slate', rgb: { r: 109, g: 117, b: 141 } },
- 62: { id: 60, name: 'Light Slate', rgb: { r: 179, g: 185, b: 209 } },
- 63: { id: 0, name: 'Transparent', rgb: null },
- }, // --- END: Color data ---
- // Optimized CSS Classes for reuse
- CSS_CLASSES: {
- BUTTON_PRIMARY: `
- background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
- color: white; border: none; border-radius: 8px; padding: 10px 16px;
- cursor: pointer; font-weight: 500; transition: all 0.3s ease;
- display: flex; align-items: center; gap: 8px;
- `,
- BUTTON_SECONDARY: `
- background: rgba(255,255,255,0.1); color: white;
- border: 1px solid rgba(255,255,255,0.2); border-radius: 8px;
- padding: 8px 12px; cursor: pointer; transition: all 0.3s ease;
- `,
- MODERN_CARD: `
- background: rgba(255,255,255,0.1); border-radius: 12px;
- padding: 18px; border: 1px solid rgba(255,255,255,0.1);
- backdrop-filter: blur(5px);
- `,
- GRADIENT_TEXT: `
- background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
- -webkit-background-clip: text; -webkit-text-fill-color: transparent;
- background-clip: text; font-weight: bold;
- `,
- },
- THEMES: {
- 'Classic Autobot': {
- primary: '#000000',
- secondary: '#111111',
- accent: '#222222',
- text: '#ffffff',
- highlight: '#775ce3',
- success: '#00ff00',
- error: '#ff0000',
- warning: '#ffaa00',
- fontFamily: "'Segoe UI', Roboto, sans-serif",
- borderRadius: '12px',
- borderStyle: 'solid',
- borderWidth: '1px',
- boxShadow: '0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1)',
- backdropFilter: 'blur(10px)',
- animations: {
- glow: false,
- scanline: false,
- 'pixel-blink': false,
- },
- },
- 'Classic Light': {
- primary: '#E0E0E1',
- secondary: '#FBFBFB',
- accent: '#F3F3F3',
- text: '#203C5D',
- highlight: '#203C5D',
- success: '#28a745',
- error: '#dc3545',
- warning: '#ffc107',
- fontFamily: "'Segoe UI', Roboto, sans-serif",
- borderRadius: '12px',
- borderStyle: 'solid',
- borderWidth: '1px',
- boxShadow: '0 8px 32px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.08)',
- backdropFilter: 'blur(10px)',
- animations: {
- glow: false,
- scanline: false,
- 'pixel-blink': false,
- },
- },
- 'Neon Retro': {
- primary: '#1a1a2e',
- secondary: '#16213e',
- accent: '#0f3460',
- text: '#00ff41',
- highlight: '#ff6b35',
- success: '#39ff14',
- error: '#ff073a',
- warning: '#ffff00',
- neon: '#00ffff',
- purple: '#bf00ff',
- pink: '#ff1493',
- fontFamily: "'Press Start 2P', monospace",
- borderRadius: '0',
- borderStyle: 'solid',
- borderWidth: '3px',
- boxShadow: '0 0 20px rgba(0, 255, 65, 0.3), inset 0 0 20px rgba(0, 255, 65, 0.1)',
- backdropFilter: 'none',
- animations: {
- glow: true,
- scanline: true,
- 'pixel-blink': true,
- },
- },
- 'Neon Retro Cyan': {
- primary: '#1959A1',
- secondary: '#3C74AF',
- accent: '#538CC0',
- text: '#81DCF7',
- highlight: '#EA9C00',
- success: '#39ff14',
- error: '#ff073a',
- warning: '#ffff00',
- neon: '00ffff',
- purple: '#bf00ff',
- pink: '#ff1493',
- fontFamily: "'Press Start 2P', monospace",
- borderRadius: '0',
- borderStyle: 'solid',
- borderWidth: '3px',
- boxShadow: '0 0 20px rgba(234 156 0, 0.3), inset 0 0 20px rgba(234 156 0, 0.1)',
- backdropFilter: 'none',
- animations: {
- glow: true,
- scanline: true,
- 'pixel-blink': true,
- },
- },
- 'Neon Retro Light': {
- primary: '#E0E0E1',
- secondary: '#FBFBFB',
- accent: '#F3F3F3',
- text: '#203C5D',
- highlight: '#203C5D',
- success: '#39ff14',
- error: '#ff073a',
- warning: '#ffff00',
- neon: '#203C5D',
- purple: '#bf00ff',
- pink: '#ff1493',
- fontFamily: "'Press Start 2P', monospace",
- borderRadius: '0',
- borderStyle: 'solid',
- borderWidth: '3px',
- boxShadow: '0 0 20px rgba(234 156 0, 0.3), inset 0 0 20px rgba(234 156 0, 0.1)',
- backdropFilter: 'none',
- animations: {
- glow: true,
- scanline: true,
- 'pixel-blink': true,
- },
- },
- 'Acrylic': {
- primary: '#00000080',
- secondary: '#00000040',
- accent: 'rgba(0,0,0,0.75)',
- text: '#ffffff',
- highlight: '#ffffff',
- success: '#00e500',
- error: '#e50000',
- warning: '#e5e500',
- fontFamily: "'Inter', 'Apple Color Emoji'",
- borderRadius: '10px',
- borderStyle: 'solid',
- borderWidth: '0px',
- boxShadow: 'none',
- backdropFilter: 'blur(20px)',
- animations: {
- glow: false,
- scanline: false,
- 'pixel-blink': false,
- },
- },
- },
- currentTheme: 'Classic Autobot',
- PAINT_UNAVAILABLE: true,
- COORDINATE_MODE: 'rows',
- COORDINATE_DIRECTION: 'top-left',
- COORDINATE_SNAKE: true,
- COORDINATE_BLOCK_WIDTH: 6,
- COORDINATE_BLOCK_HEIGHT: 2,
- autoSwap: true,
- autoBuy: 'none', // "none", "max_charges", or "paint_charges"
- autoBuyToggle: false,
- maxChargesStopEnable: false,
- maxChargesBeforeStop: 1500,
- };
-
- // Expose CONFIG globally for the utils manager and other modules
- window.CONFIG = CONFIG;
-
- const getCurrentTheme = () => CONFIG.THEMES[CONFIG.currentTheme];
-
- const switchTheme = (themeName) => {
- if (CONFIG.THEMES[themeName]) {
- CONFIG.currentTheme = themeName;
- saveThemePreference();
-
- // APPLY THEME VARS/CLASS (new)
- applyTheme();
-
- // Recreate UI (kept for now)
- createUI();
- }
- };
-
- // Add this helper (place it after getCurrentTheme/switchTheme definitions)
- function applyTheme() {
- const theme = getCurrentTheme();
- console.group('%cšØ Applying Theme in Auto-Image Script', 'color: #8b5cf6; font-weight: bold;');
- console.log(`%cšÆ Target theme: ${CONFIG.currentTheme}`, 'color: #8b5cf6;');
-
- // Toggle theme class on documentElement so CSS vars cascade to our UI
- document.documentElement.classList.remove(
- 'wplace-theme-classic',
- 'wplace-theme-classic-light',
- 'wplace-theme-acrylic',
- 'wplace-theme-neon',
- 'wplace-theme-neon-cyan',
- 'wplace-theme-neon-light'
- );
-
- let themeClass = 'wplace-theme-classic'; // default
- let themeFileName = 'classic'; // corresponding file name
-
- if (CONFIG.currentTheme === 'Neon Retro') {
- themeClass = 'wplace-theme-neon';
- themeFileName = 'neon';
- } else if (CONFIG.currentTheme === 'Classic Light') {
- themeClass = 'wplace-theme-classic-light';
- themeFileName = 'classic-light';
- } else if (CONFIG.currentTheme === 'Neon Retro Cyan') {
- themeClass = 'wplace-theme-neon-cyan';
- themeFileName = 'neon-cyan';
- } else if (CONFIG.currentTheme === 'Neon Retro Light') {
- themeClass = 'wplace-theme-neon-light';
- themeFileName = 'neon-light';
- } else if (CONFIG.currentTheme === 'Acrylic') {
- themeClass = 'wplace-theme-acrylic';
- themeFileName = 'acrylic';
- }
-
- document.documentElement.classList.add(themeClass);
- console.log(`%cā
Applied theme class: ${themeClass}`, 'color: #8b5cf6;');
-
- // Use extension's applyTheme helper if available (loads from local extension resources)
- if (typeof window.applyTheme === 'function') {
- console.log(`%cš§ Using extension's applyTheme() helper for: ${themeFileName}`, 'color: #10b981; font-weight: bold;');
- const success = window.applyTheme(themeFileName);
- if (success) {
- console.log(`%cā
Theme CSS loaded from extension local resources`, 'color: #10b981; font-weight: bold;');
- console.log(` š Source: Extension local file (themes/${themeFileName}.css)`);
- console.log(` š Performance: Instant load (no network request)`);
- } else {
- console.warn(`%cā ļø Extension theme loading failed, fallback to CSS classes only`, 'color: #f59e0b;');
- console.log(` šØ Using CSS class: ${themeClass}`);
- console.log(` š Note: Theme variables may be limited without CSS file`);
- }
- } else {
- console.log(`%cš Extension applyTheme() not available, using CSS class only`, 'color: #f59e0b;');
- console.log(` šØ Using CSS class: ${themeClass}`);
- console.log(` ā ļø Note: Full theme styling requires extension helper function`);
- }
-
- // Also set CSS variables explicitly in case you want runtime overrides
- const root = document.documentElement;
- const setVar = (k, v) => {
- try {
- root.style.setProperty(k, v);
- } catch { }
- };
-
- setVar('--wplace-primary', theme.primary);
- setVar('--wplace-secondary', theme.secondary);
- setVar('--wplace-accent', theme.accent);
- setVar('--wplace-text', theme.text);
- setVar('--wplace-highlight', theme.highlight);
- setVar('--wplace-success', theme.success);
- setVar('--wplace-error', theme.error);
- setVar('--wplace-warning', theme.warning);
-
- // Typography + look
- setVar('--wplace-font', theme.fontFamily || "'Segoe UI', Roboto, sans-serif");
- setVar('--wplace-radius', '' + (theme.borderRadius || '12px'));
- setVar('--wplace-border-style', '' + (theme.borderStyle || 'solid'));
- setVar('--wplace-border-width', '' + (theme.borderWidth || '1px'));
- setVar('--wplace-backdrop', '' + (theme.backdropFilter || 'blur(10px)'));
- setVar('--wplace-border-color', 'rgba(255,255,255,0.1)');
-
- console.log(`%cšØ Theme application complete`, 'color: #8b5cf6; font-weight: bold;');
- console.groupEnd();
- }
-
- const saveThemePreference = () => {
- try {
- localStorage.setItem('wplace-theme', CONFIG.currentTheme);
- } catch (e) {
- console.warn('Could not save theme preference:', e);
- }
- };
-
- const loadThemePreference = () => {
- try {
- const saved = localStorage.getItem('wplace-theme');
- if (saved && CONFIG.THEMES[saved]) {
- CONFIG.currentTheme = saved;
- }
- } catch (e) {
- console.warn('Could not load theme preference:', e);
- }
- };
-
- // Dynamically loaded translations
- window.loadedTranslations = {};
-
- // Available languages
- const AVAILABLE_LANGUAGES = [
- 'en',
- 'es',
- 'ru',
- 'pt',
- 'vi',
- 'fr',
- 'id',
- 'tr',
- 'zh-CN',
- 'zh-TW',
- 'ja',
- 'ko',
- 'uk',
- ];
-
- // Function to load translations from JSON file with retry mechanism
- const loadTranslations = async (language, retryCount = 0) => {
- if (window.loadedTranslations[language]) {
- return window.loadedTranslations[language];
- }
-
- console.group(`%cš Loading ${language.toUpperCase()} translations`, 'color: #06b6d4; font-weight: bold;');
-
- // First try: Check if extension has loaded local resources
- if (window.AUTOBOT_LANGUAGES && Object.keys(window.AUTOBOT_LANGUAGES).length > 0) {
- console.log(`%cš Checking extension local resources...`, 'color: #06b6d4;');
-
- const langFile = `${language}.json`;
- const availableFiles = Object.keys(window.AUTOBOT_LANGUAGES || {});
- const regionalMatch = availableFiles.find(file => file.toLowerCase().startsWith(`${language.toLowerCase()}-`));
- const resolvedFile = window.AUTOBOT_LANGUAGES[langFile] ? langFile : regionalMatch;
-
- if (resolvedFile && window.AUTOBOT_LANGUAGES[resolvedFile]) {
- const translations = window.AUTOBOT_LANGUAGES[resolvedFile];
-
- // Validate that translations is an object with keys
- if (
- typeof translations === 'object' &&
- translations !== null &&
- Object.keys(translations).length > 0
- ) {
- window.loadedTranslations[language] = translations;
- console.log(`%cā
Loaded ${language} translations from EXTENSION LOCAL FILES`, 'color: #10b981; font-weight: bold;');
- console.log(` š Source: Extension local storage (chrome-extension://)`);
- if (resolvedFile !== langFile) {
- console.log(` š Resolved locale: ${resolvedFile.replace('.json', '')}`);
- }
- console.log(` š Keys count: ${Object.keys(translations).length}`);
- console.log(` š Performance: Instant load (no network request)`);
- console.groupEnd();
- return translations;
- }
- } else {
- console.log(`%cš ${langFile} not found in extension resources`, 'color: #f59e0b;');
- console.log(` š Available in extension: ${Object.keys(window.AUTOBOT_LANGUAGES).join(', ')}`);
- }
- } else {
- console.log(`%cš No extension local resources available`, 'color: #f59e0b;');
- console.log(` š window.AUTOBOT_LANGUAGES: ${typeof window.AUTOBOT_LANGUAGES}`);
- }
-
- // Second try: Use helper function if available
- if (typeof window.getLanguage === 'function') {
- console.log(`%cš§ Trying extension getLanguage() helper...`, 'color: #06b6d4;');
- try {
- const translations = window.getLanguage(language);
- if (
- typeof translations === 'object' &&
- translations !== null &&
- Object.keys(translations).length > 0
- ) {
- window.loadedTranslations[language] = translations;
- console.log(`%cā
Loaded ${language} translations via extension helper`, 'color: #10b981; font-weight: bold;');
- console.log(` š Source: Extension getLanguage() function`);
- console.log(` š Keys count: ${Object.keys(translations).length}`);
- console.groupEnd();
- return translations;
- }
- } catch (error) {
- console.warn(`%cā ļø Extension helper failed:`, 'color: #f59e0b;', error);
- }
- }
-
- // Fallback: Load from CDN (original behavior)
- console.log(`%cš Falling back to CDN loading...`, 'color: #8b5cf6;');
- const url = `https://wplace-autobot.github.io/WPlace-AutoBOT/main/lang/${language}.json`;
- const maxRetries = 3;
- const baseDelay = 1000; // 1 second
-
- try {
- if (retryCount === 0) {
- console.log(`š Loading ${language} translations from CDN...`);
- } else {
- console.log(
- `š Retrying ${language} translations (attempt ${retryCount + 1}/${maxRetries + 1})...`
- );
- }
-
- const response = await fetch(url);
- if (response.ok) {
- const translations = await response.json();
-
- // Validate that translations is an object with keys
- if (
- typeof translations === 'object' &&
- translations !== null &&
- Object.keys(translations).length > 0
- ) {
- window.loadedTranslations[language] = translations;
- console.log(
- `%cš Loaded ${language} translations from CDN (${Object.keys(translations).length} keys)`, 'color: #f59e0b; font-weight: bold;'
- );
- console.log(` š Source: CDN (wplace-autobot.github.io)`);
- console.log(` š URL: ${url}`);
- console.log(` ā ļø Performance: Network request required`);
- console.groupEnd();
- return translations;
- } else {
- console.warn(`ā Invalid translation format for ${language}`);
- throw new Error('Invalid translation format');
- }
- } else {
- console.warn(
- `ā CDN returned HTTP ${response.status}: ${response.statusText} for ${language} translations`
- );
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
- }
- } catch (error) {
- console.error(
- `ā Failed to load ${language} translations from CDN (attempt ${retryCount + 1}):`,
- error
- );
-
- // Retry with exponential backoff
- if (retryCount < maxRetries) {
- const delay = baseDelay * Math.pow(2, retryCount);
- console.log(`ā³ Retrying in ${delay}ms...`);
- await new Promise((resolve) => setTimeout(resolve, delay));
- console.groupEnd();
- return loadTranslations(language, retryCount + 1);
- }
- }
-
- console.error(`%cš„ All translation loading methods failed for ${language}`, 'color: #ef4444; font-weight: bold;');
- console.groupEnd();
- return null;
- };
-
- const loadLanguagePreference = async () => {
- const savedLanguage = localStorage.getItem('wplace_language');
- const browserLocale = navigator.language;
- const browserLanguage = browserLocale.split('-')[0];
-
- let selectedLanguage = 'en'; // Default fallback
-
- try {
- // Check if we have the saved language available
- if (savedLanguage && AVAILABLE_LANGUAGES.includes(savedLanguage)) {
- selectedLanguage = savedLanguage;
- console.log(`š Using saved language preference: ${selectedLanguage}`);
- }
- // Try full locale match (e.g. "zh-CN", "zh-TW" etc)
- else if (AVAILABLE_LANGUAGES.includes(browserLocale)) {
- selectedLanguage = browserLocale;
- localStorage.setItem('wplace_language', browserLocale);
- console.log(`š Using browser locale: ${selectedLanguage}`);
- }
- // Try base language match (e.g. "en" for "en-US" or "en-GB" etc)
- else if (AVAILABLE_LANGUAGES.includes(browserLanguage)) {
- selectedLanguage = browserLanguage;
- localStorage.setItem('wplace_language', browserLanguage);
- console.log(`š Using browser language: ${selectedLanguage}`);
- }
- // Use English as fallback
- else {
- console.log(`š No matching language found, using English fallback`);
- }
-
- // Set the language in state first
- state.language = selectedLanguage;
-
- // Only load translations if not already loaded and not English (which should already be loaded)
- if (selectedLanguage !== 'en' && !window.loadedTranslations[selectedLanguage]) {
- const loaded = await loadTranslations(selectedLanguage);
- if (!loaded) {
- console.warn(
- `ā ļø Failed to load ${selectedLanguage} translations, falling back to English`
- );
- state.language = 'en';
- localStorage.setItem('wplace_language', 'en');
- }
- }
- } catch (error) {
- console.error(`ā Error in loadLanguagePreference:`, error);
- state.language = 'en'; // Always ensure we have a valid language
- }
- };
-
- // Simple user notification function for critical issues
- const showTranslationWarning = (message) => {
- try {
- // Create a simple temporary notification banner
- const warning = document.createElement('div');
- warning.style.cssText = `
- position: fixed; top: 10px; right: 10px; z-index: 10001;
- background: rgba(255, 193, 7, 0.95); color: #212529; padding: 12px 16px;
- border-radius: 8px; font-size: 14px; font-weight: 500;
- box-shadow: 0 4px 12px rgba(0,0,0,0.3); border: 1px solid rgba(255, 193, 7, 0.8);
- max-width: 300px; word-wrap: break-word;
- `;
- warning.textContent = message;
- document.body.appendChild(warning);
-
- // Auto-remove after 8 seconds
- setTimeout(() => {
- if (warning.parentNode) {
- warning.remove();
- }
- }, 8000);
- } catch (e) {
- // If DOM manipulation fails, just log
- console.warn('Failed to show translation warning UI:', e);
- }
- };
-
- // Initialize translations function
- const initializeTranslations = async () => {
- try {
- console.log('š Initializing translation system...');
-
- // Always ensure English is loaded as fallback first
- if (!window.loadedTranslations['en']) {
- const englishLoaded = await loadTranslations('en');
- if (!englishLoaded) {
- console.warn('ā ļø Failed to load English translations from CDN, using fallback');
- showTranslationWarning('ā ļø Translation loading failed, using basic fallbacks');
- }
- }
-
- // Then load user's language preference
- await loadLanguagePreference();
-
- console.log(`ā
Translation system initialized. Active language: ${state.language}`);
- } catch (error) {
- console.error('ā Translation initialization failed:', error);
- // Ensure state has a valid language even if loading fails
- if (!state.language) {
- state.language = 'en';
- }
- console.warn('ā ļø Using fallback translations due to initialization failure');
- showTranslationWarning('ā ļø Translation system error, using basic English');
- }
- };
-
- // Emergency fallback TEXT (minimal)
- const FALLBACK_TEXT = {
- en: {
- title: 'WPlace Auto-Image',
- toggleOverlay: 'Toggle Overlay',
- scanColors: 'Scan Colors',
- uploadImage: 'Upload Image',
- resizeImage: 'Resize Image',
- selectPosition: 'Select Position',
- startPainting: 'Start Painting',
- stopPainting: 'Stop Painting',
- progress: 'Progress',
- pixels: 'Pixels',
- charges: 'Charges',
- batchSize: 'Batch Size',
- paintingOrder: 'Painting Order',
- paintingOrderSequential: 'Sequential (Top to Bottom)',
- paintingOrderColorByColor: 'Color by Color',
- currentlyPaintingColor: 'Currently painting: {colorName}',
- colorProgress: '{painted} of {total} {colorName} pixels',
- cooldownSettings: 'Cooldown Settings',
- waitCharges: 'Wait for Charges',
- settings: 'Settings',
- showStats: 'Show Statistics',
- compactMode: 'Compact Mode',
- minimize: 'Minimize',
- tokenCapturedSuccess: 'Token captured successfully',
- turnstileInstructions: 'Complete the verification',
- hideTurnstileBtn: 'Hide',
- notificationsNotSupported: 'Notifications not supported',
- chargesReadyMessage: 'Charges are ready',
- chargesReadyNotification: 'WPlace AutoBot',
- initMessage: "Click 'Upload Image' to begin",
- },
- };
-
- // Safe translation function with multiple fallback levels
- const getText = (key, replacements = {}) => {
- // Try current language first
- let text = window.loadedTranslations[state.language]?.[key];
-
- // Fallback to English translations
- if (!text && state.language !== 'en') {
- text = window.loadedTranslations['en']?.[key];
- }
-
- // Fallback to hardcoded English
- if (!text) {
- text = FALLBACK_TEXT['en']?.[key];
- }
-
- // Last resort - return the key itself
- if (!text) {
- console.warn(`ā ļø Missing translation for key: ${key}`);
- return key;
- }
-
- // Handle string replacements like {count}, {time}, etc.
- return Object.entries(replacements).reduce((result, [placeholder, value]) => {
- return result.replace(new RegExp(`\\{${placeholder}\\}`, 'g'), value);
- }, text);
- };
-
- // UNIFIED ACCOUNT MANAGER
- class AccountManager {
- constructor() {
- this.accounts = [];
- this.currentIndex = 0;
- }
-
- // Load accounts from storage
- async loadAccounts() {
- try {
- // Get tokens from localStorage
- const tokens = JSON.parse(localStorage.getItem("accounts")) || [];
-
- // Get account info from storage
- const infoAccountsResult = await new Promise((resolve) => {
- if (typeof chrome !== 'undefined' && chrome.storage) {
- chrome.storage.local.get('infoAccounts', (result) => {
- resolve(result.infoAccounts || []);
- });
- } else {
- // Fallback for when chrome storage isn't available
- resolve([]);
- }
- });
-
- console.log(`š„ [ACCOUNT MANAGER] Loading ${tokens.length} accounts`);
-
- this.accounts = tokens.map((token, index) => {
- const info = infoAccountsResult.find(acc => acc.token === token);
- return {
- token,
- displayName: info?.displayName || info?.name || `Account ${index + 1}`,
- ID: info?.ID || null,
- Charges: info?.Charges || 0,
- Max: info?.Max || 0,
- Droplets: info?.Droplets || 0,
- isCurrent: info?.isCurrent || false,
- lastUsed: info?.lastUsed || null,
- lastImported: info?.lastImported || null
- };
- });
-
- // Find current account index
- this.currentIndex = this.accounts.findIndex(acc => acc.isCurrent);
- if (this.currentIndex === -1) this.currentIndex = 0;
-
- console.log(`ā
[ACCOUNT MANAGER] Loaded ${this.accounts.length} accounts, current index: ${this.currentIndex}`);
- console.log(`šÆ [ACCOUNT MANAGER] Current account: ${this.getCurrentAccount()?.displayName || 'None'}`);
-
- // Auto-refresh account statuses after loading
- if (this.accounts.length > 0) {
- console.log('š [ACCOUNT MANAGER] Auto-refreshing account statuses...');
- // Use setTimeout to avoid blocking the load operation
- setTimeout(async () => {
- try {
- await fetchAllAccountDetails();
- console.log('ā
[ACCOUNT MANAGER] Auto-refresh completed');
- } catch (error) {
- console.warn('ā ļø [ACCOUNT MANAGER] Auto-refresh failed:', error);
- }
- }, 100);
- }
-
- return this.accounts;
- } catch (error) {
- console.error('ā [ACCOUNT MANAGER] Error loading accounts:', error);
- this.accounts = [];
- this.currentIndex = 0;
- return [];
- }
- }
-
- // Simple linear switching
- switchToNext() {
- if (this.accounts.length === 0) return null;
-
- const previousIndex = this.currentIndex;
- this.currentIndex = (this.currentIndex + 1) % this.accounts.length;
-
- console.log(`š [ACCOUNT MANAGER] Switching: ${previousIndex} ā ${this.currentIndex}`);
- console.log(`šÆ [ACCOUNT MANAGER] Target: ${this.accounts[this.currentIndex].displayName}`);
-
- // Update current flags
- this.accounts.forEach((acc, idx) => {
- acc.isCurrent = idx === this.currentIndex;
- });
-
- return this.accounts[this.currentIndex];
- }
-
- getCurrentAccount() {
- return this.accounts[this.currentIndex] || null;
- }
-
- getAccountByIndex(index) {
- return this.accounts[index] || null;
- }
-
- updateAccountData(tokenOrData, dataObject = null) {
- let accountData;
- let targetAccount;
-
- if (dataObject !== null) {
- // Called with (token, dataObject) format
- const token = tokenOrData;
- accountData = dataObject;
- targetAccount = this.accounts.find(acc => acc.token === token);
- if (!targetAccount) {
- console.warn(`ā ļø [ACCOUNT MANAGER] Account with token not found for update`);
- return;
- }
- } else {
- // Called with (dataObject) format - update current account
- accountData = tokenOrData;
- targetAccount = this.getCurrentAccount();
- }
-
- if (targetAccount && accountData) {
- // Update all provided properties
- if (accountData.charges !== undefined || accountData.Charges !== undefined) {
- targetAccount.Charges = Math.floor(accountData.charges || accountData.Charges || 0);
- }
- if (accountData.max !== undefined || accountData.Max !== undefined) {
- targetAccount.Max = Math.floor(accountData.max || accountData.Max || 0);
- }
- if (accountData.droplets !== undefined || accountData.Droplets !== undefined) {
- targetAccount.Droplets = Math.floor(accountData.droplets || accountData.Droplets || 0);
- }
- if (accountData.id !== undefined || accountData.ID !== undefined) {
- targetAccount.ID = accountData.id || accountData.ID || targetAccount.ID;
- }
- if (accountData.displayName !== undefined) {
- targetAccount.displayName = accountData.displayName;
- }
- if (accountData.isCurrent !== undefined) {
- // Clear isCurrent from all accounts first, then set for target
- this.accounts.forEach(acc => acc.isCurrent = false);
- targetAccount.isCurrent = accountData.isCurrent;
- }
- if (accountData.cooldown !== undefined) {
- targetAccount.cooldown = accountData.cooldown;
- }
-
- console.log(`š [ACCOUNT MANAGER] Updated ${targetAccount.displayName}: ā”${targetAccount.Charges}/${targetAccount.Max} š§${targetAccount.Droplets}${targetAccount.isCurrent ? ' (CURRENT)' : ''}`);
- }
- }
-
- getAccountCount() {
- return this.accounts.length;
- }
-
- getAllAccounts() {
- return [...this.accounts]; // Return copy to prevent direct mutation
- }
-
- setCurrentIndex(index) {
- if (index >= 0 && index < this.accounts.length) {
- this.currentIndex = index;
- console.log(`š [ACCOUNT MANAGER] Current index set to: ${index}`);
- } else {
- console.warn(`ā ļø [ACCOUNT MANAGER] Invalid index ${index}, keeping current: ${this.currentIndex}`);
- }
- }
-
- getNextAccount() {
- if (this.accounts.length === 0) return null;
- const nextIndex = (this.currentIndex + 1) % this.accounts.length;
- return this.accounts[nextIndex];
- }
- }
-
- // Create global account manager instance
- const accountManager = new AccountManager();
-
- // GLOBAL STATE
- const state = {
- running: false,
- imageLoaded: false,
- processing: false,
- totalPixels: 0,
- paintedPixels: 0,
- preFilteringDone: false, // Track if pre-filtering detection has been done this session
- progressResetDone: false, // Track if progress reset has been done this save file session
- availableColors: [],
- activeColorPalette: [], // User-selected colors for conversion
- paintWhitePixels: true, // Default to ON
- fullChargeData: null,
- fullChargeInterval: null,
- paintTransparentPixels: false, // Default to OFF
- displayCharges: 0,
- preciseCurrentCharges: 0,
- maxCharges: 1, // Default max charges
- cooldown: CONFIG.COOLDOWN_DEFAULT,
- imageData: null,
- stopFlag: false,
- colorsChecked: false,
- startPosition: null,
- selectingPosition: false,
- region: null,
- minimized: false,
- lastPosition: { x: 0, y: 0 },
- lastPaintedPosition: { x: 0, y: 0 }, // Track last successfully painted coordinate
- estimatedTime: 0,
- language: 'en',
- paintingSpeed: CONFIG.PAINTING_SPEED.DEFAULT, // pixels batch size
- batchMode: CONFIG.BATCH_MODE, // "normal" or "random"
- paintingOrder: CONFIG.PAINTING_ORDER, // "sequential" or "color-by-color"
- currentPaintingColor: null, // Track current color being painted in color-by-color mode
- randomBatchMin: CONFIG.RANDOM_BATCH_RANGE.MIN, // Random range minimum
- randomBatchMax: CONFIG.RANDOM_BATCH_RANGE.MAX, // Random range maximum
- cooldownChargeThreshold: CONFIG.COOLDOWN_CHARGE_THRESHOLD,
- chargesThresholdInterval: null,
- tokenSource: CONFIG.TOKEN_SOURCE, // "generator" or "manual"
- initialSetupComplete: false, // Track if initial startup setup is complete (only happens once)
- overlayOpacity: CONFIG.OVERLAY.OPACITY_DEFAULT,
- blueMarbleEnabled: CONFIG.OVERLAY.BLUE_MARBLE_DEFAULT,
- ditheringEnabled: false,
- // Advanced color matching settings
- colorMatchingAlgorithm: 'lab',
- enableChromaPenalty: true,
- chromaPenaltyWeight: 0.15,
- customTransparencyThreshold: CONFIG.TRANSPARENCY_THRESHOLD,
- customWhiteThreshold: CONFIG.WHITE_THRESHOLD,
- resizeSettings: null,
- originalImage: null,
- resizeIgnoreMask: null,
- paintUnavailablePixels: CONFIG.PAINT_UNAVAILABLE,
- // Coordinate generation settings
- coordinateMode: CONFIG.COORDINATE_MODE,
- coordinateDirection: CONFIG.COORDINATE_DIRECTION,
- coordinateSnake: CONFIG.COORDINATE_SNAKE,
- blockWidth: CONFIG.COORDINATE_BLOCK_WIDTH,
- blockHeight: CONFIG.COORDINATE_BLOCK_HEIGHT,
- notificationsEnabled: CONFIG.NOTIFICATIONS.ENABLED,
- notifyOnChargesReached: CONFIG.NOTIFICATIONS.ON_CHARGES_REACHED,
- notifyOnlyWhenUnfocused: CONFIG.NOTIFICATIONS.ONLY_WHEN_UNFOCUSED,
- notificationIntervalMinutes: CONFIG.NOTIFICATIONS.REPEAT_MINUTES,
- _lastChargesNotifyAt: 0,
- _lastChargesBelow: true,
- // Smart save tracking
- _lastSavePixelCount: 0,
- _lastSaveTime: 0,
- _saveInProgress: false,
- paintedMap: null,
- accountIndex: 0, // Keep for backward compatibility with existing logic
- // Legacy state removed - now using accountManager instead
- isFetchingAllAccounts: false,
- };
-
- // Expose state globally for the utils manager and other modules
- window.state = state;
-
- let _updateResizePreview = () => { };
- let _resizeDialogCleanup = null;
-
- // --- OVERLAY UPDATE: Optimized OverlayManager class with performance improvements ---
- // OverlayManager class extracted to overlay-manager.js module
-
- // Overlay management is now handled by the OverlayManager module
- // Check for overlay manager availability
- if (!window.WPlaceOverlayManager) {
- console.error('ā WPlaceOverlayManager not available - please ensure overlay-manager.js is loaded first');
- throw new Error('OverlayManager dependency not found');
- }
-
- // Ensure we only have ONE overlay manager instance globally
- let overlayManager;
- if (!window.autoImageOverlayManager) {
- overlayManager = new window.WPlaceOverlayManager();
- window.autoImageOverlayManager = overlayManager;
- console.log('šÆ Created NEW overlay manager instance');
- } else {
- overlayManager = window.autoImageOverlayManager;
- console.log('šÆ Using EXISTING overlay manager instance');
- }
-
- // Token management is now handled by the TokenManager module
- // Global references for backward compatibility
- if (!window.WPlaceTokenManager) {
- console.error('ā WPlaceTokenManager not available - please ensure token-manager.js is loaded first');
- throw new Error('TokenManager dependency not found');
- }
-
- const tokenManager = window.WPlaceTokenManager;
- const setTurnstileToken = (token) => tokenManager.setTurnstileToken(token);
- const isTokenValid = () => tokenManager.isTokenValid();
- const invalidateToken = () => tokenManager.invalidateToken();
- const ensureToken = (forceRefresh = false) => tokenManager.ensureToken(forceRefresh);
-
- // Getter for the current token value
- const getTurnstileToken = () => tokenManager.turnstileToken;
-
- // Token promise management
- const createTokenPromise = () => {
- tokenManager.tokenPromise = new Promise((resolve) => {
- tokenManager._resolveToken = resolve;
- });
- };
-
- // Create global image processor instance for utility functions
- if (!window.WPlaceImageProcessor) {
- console.error('ā WPlaceImageProcessor not available - please ensure image-processor.js is loaded first');
- throw new Error('ImageProcessor dependency not found');
- }
-
- const globalImageProcessor = new window.WPlaceImageProcessor();
-
- // Keep these constants for compatibility with other parts of the script
- let retryCount = 0;
- const MAX_RETRIES = 10;
- const MAX_BATCH_RETRIES = 10; // Maximum attempts for batch sending
-
- function inject(callback) {
- const script = document.createElement('script');
- script.textContent = `(${callback})();`;
- document.documentElement?.appendChild(script);
- script.remove();
- }
-
- inject(() => {
- const fetchedBlobQueue = new Map();
-
- window.addEventListener('message', (event) => {
- const { source, blobID, blobData } = event.data;
- if (source === 'auto-image-overlay' && blobID && blobData) {
- const callback = fetchedBlobQueue.get(blobID);
- if (typeof callback === 'function') {
- callback(blobData);
- }
- fetchedBlobQueue.delete(blobID);
- }
- });
-
- const originalFetch = window.fetch;
- // Setup fetch interceptions
-
- window.fetch = async function (...args) {
- const response = await originalFetch.apply(this, args);
- const url = args[0] instanceof Request ? args[0].url : args[0];
-
- // TILE REQUEST logging removed to reduce console spam
-
- if (typeof url === 'string') {
- if (url.includes('https://backend.wplace.live/s0/pixel/')) {
- try {
- const payload = JSON.parse(args[1].body);
- if (payload.t) {
- console.log(
- `šā
Turnstile Token Captured - Type: ${typeof payload.t}, Value: ${payload.t
- ? typeof payload.t === 'string'
- ? payload.t.length > 50
- ? payload.t.substring(0, 50) + '...'
- : payload.t
- : JSON.stringify(payload.t)
- : 'null/undefined'
- }, Length: ${payload.t?.length || 0}`
- );
- window.postMessage({ source: 'turnstile-capture', token: payload.t }, '*');
- }
- } catch (_) {
- /* ignore */
- }
- }
-
- const contentType = response.headers.get('content-type') || '';
- if (contentType.includes('image/png') && url.includes('.png')) {
- const cloned = response.clone();
- return new Promise(async (resolve) => {
- const blobUUID = crypto.randomUUID();
- const originalBlob = await cloned.blob();
-
- fetchedBlobQueue.set(blobUUID, (processedBlob) => {
- resolve(
- new Response(processedBlob, {
- headers: cloned.headers,
- status: cloned.status,
- statusText: cloned.statusText,
- })
- );
- });
-
- window.postMessage(
- {
- source: 'auto-image-tile',
- endpoint: url,
- blobID: blobUUID,
- blobData: originalBlob,
- },
- '*'
- );
- });
- }
- }
-
- return response;
- };
- });
-
- window.addEventListener('message', (event) => {
- const { source, endpoint, blobID, blobData } = event.data;
-
- if (source === 'auto-image-tile' && endpoint && blobID && blobData) {
- overlayManager.processAndRespondToTileRequest(event.data);
- }
-
- // Token capture is now handled by TokenManager module
- // No need to handle 'turnstile-capture' events here
- });
-
- async function detectLanguage() {
- try {
- const response = await fetch('https://backend.wplace.live/me', {
- credentials: 'include',
- });
- const data = await response.json();
- state.language = data.language === 'pt' ? 'pt' : 'en';
- } catch {
- state.language = navigator.language.startsWith('pt') ? 'pt' : 'en';
- }
- }
-
- // UTILITY FUNCTIONS WRAPPER - Uses WPlaceUtilsManager for modular functionality
- const Utils = {
- // Basic utilities
- sleep: (ms) => window.globalUtilsManager ? window.globalUtilsManager.sleep(ms) : new Promise(r => setTimeout(r, ms)),
- dynamicSleep: (tickAndGetRemainingMs) => window.globalUtilsManager ? window.globalUtilsManager.dynamicSleep(tickAndGetRemainingMs) : Promise.resolve(),
- waitForSelector: (selector, interval, timeout) => window.globalUtilsManager ? window.globalUtilsManager.waitForSelector(selector, interval, timeout) : Promise.resolve(null),
- msToTimeText: (ms) => window.globalUtilsManager ? window.globalUtilsManager.msToTimeText(ms) : `${Math.ceil(ms / 1000)}s`,
- createScrollToAdjust: (element, updateCallback, min, max, step) => window.globalUtilsManager ? window.globalUtilsManager.createScrollToAdjust(element, updateCallback, min, max, step) : () => { },
-
- // Tile calculation
- calculateTileRange: (startRegionX, startRegionY, startPixelX, startPixelY, width, height, tileSize) =>
- window.globalUtilsManager ? window.globalUtilsManager.calculateTileRange(startRegionX, startRegionY, startPixelX, startPixelY, width, height, tileSize) : {},
-
- // Token management
- loadTurnstile: () => window.globalUtilsManager ? window.globalUtilsManager.loadTurnstile() : Promise.resolve(),
- ensureTurnstileContainer: () => window.globalUtilsManager ? window.globalUtilsManager.ensureTurnstileContainer() : Promise.resolve(),
- ensureTurnstileOverlayContainer: () => window.globalUtilsManager ? window.globalUtilsManager.ensureTurnstileOverlayContainer() : Promise.resolve(),
- executeTurnstile: (sitekey, action) => window.globalUtilsManager ? window.globalUtilsManager.executeTurnstile(sitekey, action) : Promise.resolve(null),
- createTurnstileWidget: (sitekey, action) => window.globalUtilsManager ? window.globalUtilsManager.createTurnstileWidget(sitekey, action) : Promise.resolve(),
- createTurnstileWidgetInteractive: (sitekey, action) => window.globalUtilsManager ? window.globalUtilsManager.createTurnstileWidgetInteractive(sitekey, action) : Promise.resolve(),
- cleanupTurnstile: () => window.globalUtilsManager ? window.globalUtilsManager.cleanupTurnstile() : Promise.resolve(),
- obtainSitekeyAndToken: (fallback) => window.globalUtilsManager ? window.globalUtilsManager.obtainSitekeyAndToken(fallback) : Promise.resolve(fallback),
-
- // DOM utilities
- createElement: (tag, props, children) => window.globalUtilsManager ? window.globalUtilsManager.createElement(tag, props, children) : document.createElement(tag),
- createButton: (id, text, icon, onClick, style) => {
- if (window.globalUtilsManager) {
- return window.globalUtilsManager.createButton(id, text, icon, onClick, style);
- }
- const btn = document.createElement('button');
- btn.id = id;
- btn.innerHTML = `${icon ? `` : ''}${text}`;
- if (onClick) btn.addEventListener('click', onClick);
- return btn;
- },
-
- // Translation with fallback
- t: (key, params) => {
- if (window.globalUtilsManager) {
- return window.globalUtilsManager.t(key, params);
- }
- return getText(key, params);
- },
- showAlert: (message, type) => {
- if (window.globalUtilsManager) {
- window.globalUtilsManager.showAlert(message, type);
- } else {
- console.log(`Alert [${type}]: ${message}`);
- }
- },
-
- hideAlert: () => {
- // Remove existing alerts immediately with safety checks
- const alerts = document.querySelectorAll('.wplace-alert-base');
- alerts.forEach(alert => {
- try {
- if (alert && alert.parentNode) {
- alert.parentNode.removeChild(alert);
- }
- } catch (error) {
- // Ignore removeChild errors if element was already removed
- console.debug('Alert already removed:', error);
- }
- });
- },
-
- // Color utilities
- colorDistance: (a, b) => window.globalUtilsManager ? window.globalUtilsManager.colorDistance(a, b) : Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)),
- findClosestPaletteColor: (r, g, b, palette) => window.globalUtilsManager ? window.globalUtilsManager.findClosestPaletteColor(r, g, b, palette) : [r, g, b],
- isWhitePixel: (r, g, b) => window.globalUtilsManager ? window.globalUtilsManager.isWhitePixel(r, g, b) : (r > 240 && g > 240 && b > 240),
- resolveColor: (targetRgb, availableColors, exactMatch) => window.globalUtilsManager ? window.globalUtilsManager.resolveColor(targetRgb, availableColors, exactMatch) : targetRgb,
-
- // CRITICAL FUNCTIONS - Direct implementations to ensure they work
- createImageUploader: () => {
- return new Promise((resolve, reject) => {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = 'image/*';
- input.onchange = (e) => {
- const file = e.target.files[0];
- if (file) {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result);
- reader.onerror = () => reject(new Error('File reading error'));
- reader.readAsDataURL(file);
- } else {
- reject(new Error('No file selected'));
- }
- };
- input.click();
- });
- },
-
- createFileUploader: () => {
- return new Promise((resolve, reject) => {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = '.json';
- input.onchange = (e) => {
- const file = e.target.files[0];
- if (file) {
- const reader = new FileReader();
- reader.onload = () => {
- try {
- const data = JSON.parse(reader.result);
- resolve(data);
- } catch (error) {
- reject(new Error('Invalid JSON file'));
- }
- };
- reader.onerror = () => reject(new Error('File reading error'));
- reader.readAsText(file);
- } else {
- reject(new Error('No file selected'));
- }
- };
- input.click();
- });
- },
-
- createFileDownloader: (data, filename) => {
- const blob = new Blob([data], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- },
-
- loadProgress: () => {
- try {
- const saved = localStorage.getItem('wplace-bot-progress');
- if (!saved) return null;
- return JSON.parse(saved);
- } catch (error) {
- console.error('Error loading progress:', error);
- return null;
- }
- },
-
- saveProgress: () => {
- try {
- // Use utils manager to build proper save structure like old version
- const data = window.globalUtilsManager ?
- window.globalUtilsManager.buildProgressData() :
- {
- timestamp: Date.now(),
- version: '2.2',
- state: {
- totalPixels: window.state.totalPixels,
- paintedPixels: window.state.paintedPixels,
- lastPosition: window.state.lastPosition,
- startPosition: window.state.startPosition,
- region: window.state.region,
- imageLoaded: window.state.imageLoaded,
- colorsChecked: window.state.colorsChecked,
- coordinateMode: window.state.coordinateMode,
- coordinateDirection: window.state.coordinateDirection,
- coordinateSnake: window.state.coordinateSnake,
- blockWidth: window.state.blockWidth,
- blockHeight: window.state.blockHeight,
- availableColors: window.state.availableColors,
- },
- imageData: window.state.imageData ? {
- width: window.state.imageData.width,
- height: window.state.imageData.height,
- pixels: Array.from(window.state.imageData.pixels),
- totalPixels: window.state.imageData.totalPixels,
- } : null,
- paintedMapPacked: window.globalUtilsManager ?
- window.globalUtilsManager.buildPaintedMapPacked() : null
- };
-
- localStorage.setItem('wplace-bot-progress', JSON.stringify(data));
- return true;
- } catch (error) {
- console.error('Error saving progress:', error);
- return false;
- }
- },
-
- clearProgress: () => {
- try {
- localStorage.removeItem('wplace-bot-progress');
- if (window.state) {
- window.state.paintedMap = null;
- window.state._lastSavePixelCount = 0;
- window.state._lastSaveTime = 0;
- }
- return true;
- } catch (error) {
- console.error('Error clearing progress:', error);
- return false;
- }
- },
-
- restoreProgress: (savedData) => {
- try {
- console.log('š [DEBUG] RestoreProgress: Starting restoration...');
- console.log('š [DEBUG] SavedData exists:', !!savedData);
- console.log('š [DEBUG] SavedData.state exists:', !!savedData?.state);
-
- if (!savedData || !savedData.state) {
- console.error('ā [DEBUG] RestoreProgress: Invalid savedData or missing state');
- return false;
- }
-
- console.log('š [DEBUG] Current window.state keys before restore:', Object.keys(window.state || {}));
- console.log('š [DEBUG] SavedData.state keys:', Object.keys(savedData.state));
-
- console.log('š [DEBUG] Performing Object.assign...');
- Object.assign(window.state, savedData.state);
-
- // Reset session-specific flags when loading a new save file
- window.state.preFilteringDone = false;
- window.state.progressResetDone = false;
- console.log('š Reset session flags for new save file load');
-
- console.log('ā
[DEBUG] Object.assign completed successfully');
-
- // Restore available colors from old save files (backward compatibility)
- if (savedData.state.availableColors && Array.isArray(savedData.state.availableColors)) {
- console.log('š [DEBUG] Restoring availableColors, count:', savedData.state.availableColors.length);
- window.state.availableColors = savedData.state.availableColors;
- window.state.colorsChecked = true; // Mark colors as checked
- console.log('ā
[DEBUG] AvailableColors restored successfully');
- } else {
- console.log('ā ļø [DEBUG] No availableColors found in savedData or not an array');
- }
-
- if (savedData.imageData) {
- console.log('š [DEBUG] Restoring imageData...');
- console.log('š [DEBUG] ImageData type check - pixels is array:', Array.isArray(savedData.imageData.pixels));
- console.log('š [DEBUG] ImageData pixels length:', savedData.imageData.pixels?.length);
-
- window.state.imageData = {
- ...savedData.imageData,
- pixels: new Uint8ClampedArray(savedData.imageData.pixels),
- };
- console.log('ā
[DEBUG] ImageData restored successfully');
- console.log('š [DEBUG] Converted pixels to Uint8ClampedArray, length:', window.state.imageData.pixels.length);
- } else {
- console.log('ā ļø [DEBUG] No imageData found in savedData');
- }
-
- if (savedData.paintedMap) {
- console.log('š [DEBUG] Restoring paintedMap...');
- window.state.paintedMap = savedData.paintedMap.map((row) => Array.from(row));
- console.log('ā
[DEBUG] PaintedMap restored successfully');
- } else {
- console.log('š [DEBUG] No paintedMap found in savedData (this is normal for extracted data)');
- }
-
- console.log('ā
[DEBUG] RestoreProgress completed successfully');
- console.log('š [DEBUG] Final window.state.imageLoaded:', window.state.imageLoaded);
- console.log('š [DEBUG] Final window.state.totalPixels:', window.state.totalPixels);
-
- return true;
- } catch (error) {
- console.error('ā [DEBUG] Error in restoreProgress:', error);
- console.error('ā [DEBUG] Error stack:', error.stack);
- console.error('ā [DEBUG] SavedData structure at error:', JSON.stringify(savedData, null, 2));
- return false;
- }
- },
-
- saveProgressToFile: () => {
- try {
- // Use utils manager to build proper save structure like old version
- const progressData = window.globalUtilsManager ?
- window.globalUtilsManager.buildProgressData() :
- {
- timestamp: Date.now(),
- version: '2.2',
- state: {
- totalPixels: window.state.totalPixels,
- paintedPixels: window.state.paintedPixels,
- lastPosition: window.state.lastPosition,
- startPosition: window.state.startPosition,
- region: window.state.region,
- imageLoaded: window.state.imageLoaded,
- colorsChecked: window.state.colorsChecked,
- coordinateMode: window.state.coordinateMode,
- coordinateDirection: window.state.coordinateDirection,
- coordinateSnake: window.state.coordinateSnake,
- blockWidth: window.state.blockWidth,
- blockHeight: window.state.blockHeight,
- availableColors: window.state.availableColors,
- },
- imageData: window.state.imageData ? {
- width: window.state.imageData.width,
- height: window.state.imageData.height,
- pixels: Array.from(window.state.imageData.pixels),
- totalPixels: window.state.imageData.totalPixels,
- } : null,
- paintedMapPacked: window.globalUtilsManager ?
- window.globalUtilsManager.buildPaintedMapPacked() : null
- };
-
- const filename = `wplace-bot-progress-${new Date()
- .toISOString()
- .slice(0, 19)
- .replace(/:/g, '-')}.json`;
- Utils.createFileDownloader(JSON.stringify(progressData, null, 2), filename);
- return true;
- } catch (error) {
- console.error('Error saving to file:', error);
- return false;
- }
- },
-
- loadProgressFromFile: async () => {
- try {
- console.log('š [DEBUG] Starting file upload process...');
- const data = await Utils.createFileUploader();
-
- console.log('š [DEBUG] File upload completed. Data type:', typeof data);
- console.log('š [DEBUG] Data exists:', !!data);
-
- if (!data) {
- console.error('ā [DEBUG] No data received from file uploader');
- throw new Error('No data received from file');
- }
-
- console.log('š [DEBUG] Data keys:', Object.keys(data));
- console.log('š [DEBUG] Data.state exists:', !!data.state);
- console.log('š [DEBUG] Data.imageData exists:', !!data.imageData);
- console.log('š [DEBUG] Data.version:', data.version);
- console.log('š [DEBUG] Data.timestamp:', data.timestamp);
-
- if (!data.state) {
- console.error('ā [DEBUG] Missing state object in loaded data');
- console.log('š [DEBUG] Available top-level keys:', Object.keys(data));
- throw new Error('Invalid file format - missing state object');
- }
-
- console.log('š [DEBUG] State object keys:', Object.keys(data.state));
- console.log('š [DEBUG] State.imageLoaded:', data.state.imageLoaded);
- console.log('š [DEBUG] State.totalPixels:', data.state.totalPixels);
- console.log('š [DEBUG] State.startPosition:', data.state.startPosition);
- console.log('š [DEBUG] State.region:', data.state.region);
-
- if (data.imageData) {
- console.log('š [DEBUG] ImageData width:', data.imageData.width);
- console.log('š [DEBUG] ImageData height:', data.imageData.height);
- console.log('š [DEBUG] ImageData pixels length:', data.imageData.pixels?.length);
- console.log('š [DEBUG] ImageData totalPixels:', data.imageData.totalPixels);
- }
-
- console.log('š [DEBUG] Calling restoreProgress...');
- const success = Utils.restoreProgress(data);
- console.log('š [DEBUG] RestoreProgress result:', success);
-
- return success;
- } catch (error) {
- console.error('ā [DEBUG] Error in loadProgressFromFile:', error);
- console.error('ā [DEBUG] Error stack:', error.stack);
- return false;
- }
- },
-
- loadExtractedFileData: async () => {
- try {
- console.log('š [DEBUG] Starting Art-Extractor file upload process...');
- const data = await Utils.createFileUploader();
-
- console.log('š [DEBUG] File upload completed. Data type:', typeof data);
- console.log('š [DEBUG] Data exists:', !!data);
- console.log('š Raw loaded data:', data);
-
- if (!data) {
- console.error('ā [DEBUG] No data received from file uploader');
- return null;
- }
-
- console.log('š [DEBUG] Data keys:', Object.keys(data));
- console.log('š [DEBUG] Data.state exists:', !!data.state);
- console.log('š [DEBUG] Data.imageData exists:', !!data.imageData);
- console.log('š [DEBUG] Data.version:', data.version);
- console.log('š [DEBUG] Data.timestamp:', data.timestamp);
-
- console.log('š [DEBUG] Returning raw data object for Load Extracted feature');
- return data;
- } catch (error) {
- console.error('ā [DEBUG] Error in loadExtractedFileData:', error);
- return null;
- }
- },
-
- // Other utilities - delegate to globalUtilsManager or provide simple fallbacks
- extractAvailableColors: () => {
- if (window.globalUtilsManager) {
- return window.globalUtilsManager.extractAvailableColors();
- } else if (window.globalImageProcessor) {
- return window.globalImageProcessor.extractAvailableColors(window.CONFIG.COLOR_MAP);
- } else {
- return [];
- }
- },
- formatTime: (ms) => window.globalUtilsManager ? window.globalUtilsManager.formatTime(ms) : `${Math.floor(ms / 1000)}s`,
- calculateEstimatedTime: (remainingPixels, charges, cooldown) => window.globalUtilsManager ? window.globalUtilsManager.calculateEstimatedTime(remainingPixels, charges, cooldown) : 0,
- initializePaintedMap: (width, height) => window.globalUtilsManager ? window.globalUtilsManager.initializePaintedMap(width, height) : console.log('Painted map not available'),
- markPixelPainted: (x, y, regionX, regionY) => window.globalUtilsManager ? window.globalUtilsManager.markPixelPainted(x, y, regionX, regionY) : false,
- isPixelPainted: (x, y, regionX, regionY) => window.globalUtilsManager ? window.globalUtilsManager.isPixelPainted(x, y, regionX, regionY) : false,
- shouldAutoSave: () => window.globalUtilsManager ? window.globalUtilsManager.shouldAutoSave() : false,
- performSmartSave: () => window.globalUtilsManager ? window.globalUtilsManager.performSmartSave() : false,
- packPaintedMapToBase64: (paintedMap, width, height) => window.globalUtilsManager ? window.globalUtilsManager.packPaintedMapToBase64(paintedMap, width, height) : null,
- unpackPaintedMapFromBase64: (base64, width, height) => window.globalUtilsManager ? window.globalUtilsManager.unpackPaintedMapFromBase64(base64, width, height) : null,
- migrateProgressToV2: (saved) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgressToV2(saved) : saved,
- migrateProgressToV21: (saved) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgressToV21(saved) : saved,
- migrateProgressToV22: (data) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgressToV22(data) : data,
- buildPaintedMapPacked: () => window.globalUtilsManager ? window.globalUtilsManager.buildPaintedMapPacked() : null,
- buildProgressData: () => window.globalUtilsManager ? window.globalUtilsManager.buildProgressData() : {},
- migrateProgress: (saved) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgress(saved) : saved,
- restoreOverlayFromData: () => window.globalUtilsManager ? window.globalUtilsManager.restoreOverlayFromData() : Promise.resolve(false),
- updateCoordinateUI: (config) => window.globalUtilsManager ? window.globalUtilsManager.updateCoordinateUI(config) : console.log('Coordinate UI update not available'),
- };
-
- // IMAGE PROCESSOR WRAPPER - Uses WPlaceImageProcessor for modular functionality
- class ImageProcessor {
- constructor(imageSrc) {
- // Create instance of the modular ImageProcessor
- this._processor = new window.WPlaceImageProcessor(imageSrc);
-
- // Legacy compatibility properties
- this.imageSrc = imageSrc;
- this.img = null;
- this.canvas = null;
- this.ctx = null;
- }
-
- async load() {
- await this._processor.load();
-
- // Update legacy properties for compatibility
- this.img = this._processor.img;
- this.canvas = this._processor.canvas;
- this.ctx = this._processor.ctx;
- }
-
- getDimensions() {
- return this._processor.getDimensions();
- }
-
- getPixelData() {
- return this._processor.getPixelData();
- }
-
- resize(newWidth, newHeight) {
- const result = this._processor.resize(newWidth, newHeight);
-
- // Update legacy properties
- this.img = this._processor.img;
- this.canvas = this._processor.canvas;
- this.ctx = this._processor.ctx;
-
- return result;
- }
-
- generatePreview(width, height) {
- return this._processor.generatePreview(width, height);
- }
-
- // Cleanup method
- cleanup() {
- if (this._processor) {
- this._processor.cleanup();
- }
- }
- }
-
- // WPLACE API SERVICE
- const WPlaceService = {
- async paintPixelInRegion(regionX, regionY, pixelX, pixelY, color) {
- try {
- await ensureToken();
- if (!getTurnstileToken()) return 'token_error';
- const payload = {
- coords: [pixelX, pixelY],
- colors: [color],
- t: getTurnstileToken(),
- fp: fpStr32,
- };
- var token = await createWasmToken(regionX, regionY, payload);
- const res = await fetch(`https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`, {
- method: 'POST',
- headers: { 'Content-Type': 'text/plain;charset=UTF-8', "x-pawtect-token": token },
- credentials: 'include',
- body: JSON.stringify(payload),
- });
- if (res.status === 403) {
- console.error('ā 403 Forbidden. Turnstile token might be invalid or expired.');
- setTurnstileToken(null);
- createTokenPromise();
- return 'token_error';
- }
- const data = await res.json();
- return data?.painted === 1;
- } catch (e) {
- console.error('Paint request failed:', e);
- return false;
- }
- },
-
- async getCharges() {
- try {
- const res = await fetch("https://backend.wplace.live/me", {
- credentials: "include",
- })
- const data = await res.json()
- return {
- id: data.id,
- charges: data.charges?.count || 0,
- max: data.charges?.max || 1,
- cooldown: data.charges?.next || CONFIG.COOLDOWN_DEFAULT,
- droplets: data.droplets || 0,
- }
- } catch (e) {
- console.error("Failed to get charges:", e)
- return {
- id: null,
- charges: 0,
- max: 1,
- cooldown: CONFIG.COOLDOWN_DEFAULT,
- droplets: 0,
- }
- }
- },
-
- async fetchCheck() {
- try {
- const res = await fetch("https://backend.wplace.live/me", {
- credentials: "include",
- })
- const data = await res.json()
- return {
- ID: data.id,
- Username: data.username || data.name || null,
- Charges: data.charges.count,
- Max: data.charges.max,
- Droplets: data.droplets
- }
- } catch (e) {
- console.error("Failed to get ID:", e)
- return {
-
- }
- }
- }
- };
-
- // Desktop Notification Manager
- const NotificationManager = {
- pollTimer: null,
- pollIntervalMs: 60_000,
- icon() {
- const link = document.querySelector("link[rel~='icon']");
- return link?.href || location.origin + '/favicon.ico';
- },
- async requestPermission() {
- if (!('Notification' in window)) {
- Utils.showAlert(Utils.t('notificationsNotSupported'), 'warning');
- return 'denied';
- }
- if (Notification.permission === 'granted') return 'granted';
- try {
- const perm = await Notification.requestPermission();
- return perm;
- } catch {
- return Notification.permission;
- }
- },
- canNotify() {
- return (
- state.notificationsEnabled &&
- typeof Notification !== 'undefined' &&
- Notification.permission === 'granted'
- );
- },
- notify(title, body, tag = 'wplace-charges', force = false) {
- if (!this.canNotify()) return false;
- if (!force && state.notifyOnlyWhenUnfocused && document.hasFocus()) return false;
- try {
- new Notification(title, {
- body,
- tag,
- renotify: true,
- icon: this.icon(),
- badge: this.icon(),
- silent: false,
- });
- return true;
- } catch {
- // Graceful fallback
- Utils.showAlert(body, 'info');
- return false;
- }
- },
- resetEdgeTracking() {
- state._lastChargesBelow = state.displayCharges < state.cooldownChargeThreshold;
- state._lastChargesNotifyAt = 0;
- },
- maybeNotifyChargesReached(force = false) {
- if (!state.notificationsEnabled || !state.notifyOnChargesReached) return;
- const reached = state.displayCharges >= state.cooldownChargeThreshold;
- const now = Date.now();
- const repeatMs = Math.max(1, Number(state.notificationIntervalMinutes || 5)) * 60_000;
- if (reached) {
- const shouldEdge = state._lastChargesBelow || force;
- const shouldRepeat = now - (state._lastChargesNotifyAt || 0) >= repeatMs;
- if (shouldEdge || shouldRepeat) {
- const msg = Utils.t('chargesReadyMessage', {
- current: state.displayCharges,
- max: state.maxCharges,
- threshold: state.cooldownChargeThreshold,
- });
- this.notify(Utils.t('chargesReadyNotification'), msg, 'wplace-notify-charges');
- state._lastChargesNotifyAt = now;
- }
- state._lastChargesBelow = false;
- } else {
- state._lastChargesBelow = true;
- }
- },
- startPolling() {
- this.stopPolling();
- if (!state.notificationsEnabled || !state.notifyOnChargesReached) return;
- // lightweight background polling
- this.pollTimer = setInterval(async () => {
- try {
- const { charges, cooldown, max } = await WPlaceService.getCharges();
- state.displayCharges = Math.floor(charges);
- state.cooldown = cooldown;
- state.maxCharges = Math.max(1, Math.floor(max));
- this.maybeNotifyChargesReached();
- } catch {
- /* ignore */
- }
- }, this.pollIntervalMs);
- },
- stopPolling() {
- if (this.pollTimer) {
- clearInterval(this.pollTimer);
- this.pollTimer = null;
- }
- },
- syncFromState() {
- this.resetEdgeTracking();
- if (state.notificationsEnabled && state.notifyOnChargesReached) this.startPolling();
- else this.stopPolling();
- },
- };
-
- // COLOR MATCHING FUNCTION - Optimized with caching
- const colorCache = new Map();
-
- // UI UPDATE FUNCTIONS (declared early to avoid reference errors)
- let updateUI = () => { };
- let updateStats = async () => { };
- let updateDataButtons = () => { };
-
- function updateActiveColorPalette() {
- state.activeColorPalette = [];
- const activeSwatches = document.querySelectorAll('.wplace-color-swatch.active');
- if (activeSwatches) {
- activeSwatches.forEach((swatch) => {
- const rgbStr = swatch.getAttribute('data-rgb');
- if (rgbStr) {
- const rgb = rgbStr.split(',').map(Number);
- state.activeColorPalette.push(rgb);
- }
- });
- }
- if (document.querySelector('.resize-container')?.style.display === 'block') {
- _updateResizePreview();
- }
- }
-
- function toggleAllColors(select, showingUnavailable = false) {
- const swatches = document.querySelectorAll('.wplace-color-swatch');
- if (swatches) {
- swatches.forEach((swatch) => {
- // Only toggle colors that are available or if we're showing unavailable colors
- const isUnavailable = swatch.classList.contains('unavailable');
- if (!isUnavailable || showingUnavailable) {
- // Don't try to select unavailable colors
- if (!isUnavailable) {
- swatch.classList.toggle('active', select);
- }
- }
- });
- }
- updateActiveColorPalette();
- }
-
- function unselectAllPaidColors() {
- const swatches = document.querySelectorAll('.wplace-color-swatch');
- if (swatches) {
- swatches.forEach((swatch) => {
- const colorId = parseInt(swatch.getAttribute('data-color-id'), 10);
- if (!isNaN(colorId) && colorId >= 32) {
- swatch.classList.toggle('active', false);
- }
- });
- }
- updateActiveColorPalette();
- }
-
- function initializeColorPalette(container) {
- const colorsContainer = container.querySelector('#colors-container');
- const showAllToggle = container.querySelector('#showAllColorsToggle');
- if (!colorsContainer) return;
-
- // Use already captured colors from state (captured during upload)
- // Don't re-fetch colors here, use what was captured when user clicked upload
- if (!state.availableColors || state.availableColors.length === 0) {
- // If no colors have been captured yet, show message
- colorsContainer.innerHTML = `
${Utils.t(
- 'uploadImageFirst'
- )}
`;
- return;
- }
-
- function populateColors(showUnavailable = false) {
- colorsContainer.innerHTML = '';
- let availableCount = 0;
- let totalCount = 0;
-
- // Convert COLOR_MAP to array and filter out transparent
- const allColors = Object.values(CONFIG.COLOR_MAP).filter((color) => color.rgb !== null);
-
- allColors.forEach((colorData) => {
- const { id, name, rgb } = colorData;
- const rgbKey = `${rgb.r},${rgb.g},${rgb.b}`;
- totalCount++;
-
- // Check if this color is available in the captured colors
- const isAvailable = state.availableColors.some(
- (c) => c.rgb[0] === rgb.r && c.rgb[1] === rgb.g && c.rgb[2] === rgb.b
- );
-
- // If not showing all colors and this color is not available, skip it
- if (!showUnavailable && !isAvailable) {
- return;
- }
-
- if (isAvailable) availableCount++;
-
- const colorItem = Utils.createElement('div', {
- className: 'wplace-color-item',
- });
- const swatch = Utils.createElement('button', {
- className: `wplace-color-swatch ${!isAvailable ? 'unavailable' : ''}`,
- title: `${name} (ID: ${id})${!isAvailable ? ' (Unavailable)' : ''}`,
- 'data-rgb': rgbKey,
- 'data-color-id': id,
- });
- swatch.style.backgroundColor = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
-
- // Make unavailable colors visually distinct
- if (!isAvailable) {
- swatch.style.opacity = '0.4';
- swatch.style.filter = 'grayscale(50%)';
- swatch.disabled = true;
- } else {
- // Select available colors by default
- swatch.classList.add('active');
- }
-
- const nameLabel = Utils.createElement(
- 'span',
- {
- className: 'wplace-color-item-name',
- style: !isAvailable ? 'color: #888; font-style: italic;' : '',
- },
- name + (!isAvailable ? ' (N/A)' : '')
- );
-
- // Only add click listener for available colors
- if (isAvailable) {
- swatch.addEventListener('click', () => {
- swatch.classList.toggle('active');
- updateActiveColorPalette();
- });
- }
-
- colorItem.appendChild(swatch);
- colorItem.appendChild(nameLabel);
- colorsContainer.appendChild(colorItem);
- });
-
- updateActiveColorPalette();
- }
-
- // Initialize with only available colors
- populateColors(false);
-
- // Add toggle functionality
- if (showAllToggle) {
- showAllToggle.addEventListener('change', (e) => {
- populateColors(e.target.checked);
- });
- }
-
- container
- .querySelector('#selectAllBtn')
- ?.addEventListener('click', () => toggleAllColors(true, showAllToggle?.checked));
- container
- .querySelector('#unselectAllBtn')
- ?.addEventListener('click', () => toggleAllColors(false, showAllToggle?.checked));
- container
- .querySelector('#unselectPaidBtn')
- ?.addEventListener('click', () => unselectAllPaidColors());
- }
-
- async function handleCaptcha(allowGeneration = true) {
- const startTime = performance.now();
-
- // Check user's token source preference
- if (state.tokenSource === 'manual') {
- console.log('šÆ Manual token source selected - using pixel placement automation');
- if (!allowGeneration) {
- console.log('ā Token generation disabled during processing');
- return null;
- }
- return await tokenManager.handleCaptchaFallback();
- }
-
- // Generator mode (pure) or Hybrid mode - try generator first
- try {
- // Use optimized token generation with automatic sitekey detection
- const { sitekey, token: preGeneratedToken } = await Utils.obtainSitekeyAndToken();
-
- if (!sitekey) {
- throw new Error('No valid sitekey found');
- }
-
- console.log('š Generating Turnstile token for sitekey:', sitekey);
- console.log(
- 'š§ UA:',
- navigator.userAgent.substring(0, 50) + '...',
- 'Platform:',
- navigator.platform
- );
-
- // Add additional checks before token generation
- if (!window.turnstile) {
- await Utils.loadTurnstile();
- }
-
- let token = null;
-
- // ā
Reuse pre-generated token if available and valid
- if (
- preGeneratedToken &&
- typeof preGeneratedToken === 'string' &&
- preGeneratedToken.length > 20
- ) {
- console.log('ā»ļø Reusing pre-generated token from sitekey detection phase');
- token = preGeneratedToken;
- }
- // ā
Or use globally cached token if still valid
- else if (isTokenValid()) {
- console.log('ā»ļø Using existing cached token (from previous operation)');
- token = getTurnstileToken();
- }
- // ā
Otherwise generate a new one (only if allowed)
- else {
- if (!allowGeneration) {
- console.log('ā Token expired/missing but generation disabled during processing');
- return null;
- }
- console.log('š No valid pre-generated or cached token, creating new one...');
- token = await Utils.executeTurnstile(sitekey, 'paint');
- if (token) {
- setTurnstileToken(token);
- }
- }
-
- console.log(
- `š Token received - Type: ${typeof token}, Value: ${token
- ? typeof token === 'string'
- ? token.length > 50
- ? token.substring(0, 50) + '...'
- : token
- : JSON.stringify(token)
- : 'null/undefined'
- }, Length: ${token?.length || 0}`
- );
-
- // ā
Final validation
- if (typeof token === 'string' && token.length > 20) {
- const duration = Math.round(performance.now() - startTime);
- console.log(`ā
Turnstile token generated successfully in ${duration}ms`);
- return token;
- } else {
- throw new Error(
- `Invalid or empty token received - Type: ${typeof token}, Value: ${JSON.stringify(
- token
- )}, Length: ${token?.length || 0}`
- );
- }
- } catch (error) {
- const duration = Math.round(performance.now() - startTime);
- console.error(`ā Turnstile token generation failed after ${duration}ms:`, error);
-
- // Fallback to manual pixel placement for hybrid mode
- if (state.tokenSource === 'hybrid') {
- console.log(
- 'š Hybrid mode: Generator failed, automatically switching to manual pixel placement...'
- );
- const fbToken = await tokenManager.handleCaptchaFallback();
- return fbToken;
- } else {
- // Pure generator mode - don't fallback, just fail
- throw error;
- }
- }
- }
-
- async function createUI() {
- await detectLanguage();
-
- const existingContainer = document.getElementById('wplace-image-bot-container');
- const existingStats = document.getElementById('wplace-stats-container');
- const existingSettings = document.getElementById('wplace-settings-container');
- const existingResizeContainer = document.querySelector('.resize-container');
- const existingResizeOverlay = document.querySelector('.resize-overlay');
-
- if (existingContainer) existingContainer.remove();
- if (existingStats) existingStats.remove();
- if (existingSettings) existingSettings.remove();
- if (existingResizeContainer) existingResizeContainer.remove();
- if (existingResizeOverlay) existingResizeOverlay.remove();
-
- loadThemePreference();
- await initializeTranslations();
-
- const theme = getCurrentTheme();
- applyTheme(); // <- new: set CSS vars and theme class before building UI
-
- function appendLinkOnce(href, attributes = {}) {
- // Check if a link with the same href already exists in the document head
- const exists = Array.from(document.head.querySelectorAll('link')).some(
- (link) => link.href === href
- );
- if (exists) return;
-
- // Create a new link element
- const link = document.createElement('link');
- link.rel = 'stylesheet';
- link.href = href;
-
- // Add any additional attributes (e.g., data-* attributes)
- for (const [key, value] of Object.entries(attributes)) {
- link.setAttribute(key, value);
- }
-
- // Append the link element to the document head
- document.head.appendChild(link);
- }
-
- appendLinkOnce('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css');
-
- if (theme.fontFamily.includes('Press Start 2P')) {
- appendLinkOnce('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
- }
-
- // Load auto-image-styles.css - prioritize extension local resources
- console.group('%cšØ Loading Auto-Image Styles', 'color: #8b5cf6; font-weight: bold;');
-
- let stylesLoaded = false;
-
- // First try: Check if extension has loaded local CSS resources
- if (window.AUTOBOT_THEMES && window.AUTOBOT_THEMES['auto-image-styles.css']) {
- console.log('%cš Found auto-image-styles.css in extension local resources!', 'color: #10b981; font-weight: bold;');
-
- // Check if it's already injected by the extension
- const existingStyle = document.getElementById('autobot-auto-image-styles');
- if (existingStyle) {
- console.log('%cā
Styles already injected by extension - using local version', 'color: #10b981; font-weight: bold;');
- console.log(' š Source: Extension local file (already in DOM)');
- console.log(' š Element ID: autobot-auto-image-styles');
- stylesLoaded = true;
- } else {
- // Inject it ourselves
- const styleElement = document.createElement('style');
- styleElement.id = 'autobot-auto-image-styles-script';
- styleElement.setAttribute('data-wplace-theme', 'true');
- styleElement.textContent = window.AUTOBOT_THEMES['auto-image-styles.css'];
- document.head.appendChild(styleElement);
-
- console.log('%cā
Injected auto-image-styles.css from extension local resources', 'color: #10b981; font-weight: bold;');
- console.log(' š Source: Extension local file');
- console.log(' š Size: ' + window.AUTOBOT_THEMES['auto-image-styles.css'].length + ' characters');
- console.log(' š Element ID: autobot-auto-image-styles-script');
- stylesLoaded = true;
- }
- } else {
- console.log('%cš auto-image-styles.css not found in extension resources', 'color: #f59e0b;');
- console.log(' š window.AUTOBOT_THEMES:', typeof window.AUTOBOT_THEMES);
- if (window.AUTOBOT_THEMES) {
- console.log(' š Available themes:', Object.keys(window.AUTOBOT_THEMES));
- }
- }
-
- // Fallback: Load from CDN if local resources not available
- if (!stylesLoaded) {
- console.log('%cš Falling back to CDN loading...', 'color: #8b5cf6;');
- appendLinkOnce(
- 'https://wplace-autobot.github.io/WPlace-AutoBOT/main/auto-image-styles.css',
- { 'data-wplace-theme': 'true' }
- );
- console.log('%cš Loaded auto-image-styles.css from CDN (fallback)', 'color: #f59e0b; font-weight: bold;');
- console.log(' š Source: CDN (wplace-autobot.github.io)');
- console.log(' š URL: https://wplace-autobot.github.io/WPlace-AutoBOT/main/auto-image-styles.css');
- console.log(' ā ļø Performance: Network request required');
- }
-
- console.groupEnd();
-
- // Ensure default theme is loaded from extension resources
- if (window.applyTheme && typeof window.applyTheme === 'function') {
- console.group('%cšØ Loading Default Theme from Extension', 'color: #8b5cf6; font-weight: bold;');
-
- // Determine the current theme file name
- let defaultTheme = 'classic'; // fallback
- if (CONFIG.currentTheme === 'Neon Retro') {
- defaultTheme = 'neon';
- } else if (CONFIG.currentTheme === 'Neon Retro Cyan') {
- defaultTheme = 'neon-cyan';
- } else if (CONFIG.currentTheme === 'Neon Retro Light') {
- defaultTheme = 'neon-light';
- } else if (CONFIG.currentTheme === 'Classic Light') {
- defaultTheme = 'classic-light';
- } else if (CONFIG.currentTheme === 'Acrylic') {
- defaultTheme = 'acrylic';
- }
-
- console.log(`%cšÆ Loading theme: ${defaultTheme} (${CONFIG.currentTheme})`, 'color: #8b5cf6;');
- const success = window.applyTheme(defaultTheme);
-
- if (success) {
- console.log(`%cā
Default theme loaded from extension local resources`, 'color: #10b981; font-weight: bold;');
- console.log(` š Source: Extension local file (themes/${defaultTheme}.css)`);
- console.log(` š Performance: Instant load (no CDN request)`);
- console.log(` šØ Theme: ${CONFIG.currentTheme}`);
- } else {
- console.warn(`%cā ļø Failed to load theme ${defaultTheme} from extension`, 'color: #f59e0b;');
- console.log(` š Note: Theme CSS classes will still work`);
- }
-
- console.groupEnd();
- } else {
- console.warn(`%cā ļø Extension applyTheme() function not available`, 'color: #f59e0b; font-weight: bold;');
- console.log(` š Themes will be limited to CSS classes only`);
- }
-
- const container = document.createElement('div');
- container.id = 'wplace-image-bot-container';
- container.innerHTML = `
-
-
-
-
-
- ${Utils.t('initMessage')}
-
-
-
-
-
-
-
š¼ļø Image Management
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
š® Painting Control
-
-
-
-
-
-
-
-
-
-
-
-
-
-
ā±ļø ${Utils.t('cooldownSettings')}
-
-
-
-
-
-
-
-
-
š¾ Data Management
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- const statsContainer = document.createElement('div');
- statsContainer.id = 'wplace-stats-container';
- statsContainer.style.display = 'none';
- statsContainer.innerHTML = `
-
-
-
-
-
-
${Utils.t('initMessage')}
-
-
-
-
-
-
-
-
- Account Swapper
-
-
-
-
-
-
-
-
-
- Auto Buy Charges
-
-
-
-
-
-
-
-
- All Accounts
-
-
-
-
Click the icon to load accounts.
-
-
-
- `;
-
- function initPillSelector() {
- const buttons = statsContainer.querySelectorAll(".pill-btn");
- const highlight = statsContainer.querySelector(".pill-highlight");
-
- buttons.forEach((btn, index) => {
- btn.addEventListener("click", () => {
- highlight.style.transform = `translateX(${index * 100}%)`;
-
- buttons.forEach((b) => b.classList.remove("active"));
- btn.classList.add("active");
-
- CONFIG.autoBuy = btn.dataset.mode;
- if (CONFIG.autoBuy === 'none') {
- console.log("AutoBuy disabled");
- CONFIG.autoBuyToggle = false;
- }
- else {
- CONFIG.autoBuyToggle = true;
- console.log("AutoBuy enabled");
- }
- console.log("AutoBuy mode set to:", CONFIG.autoBuy);
- });
- });
- }
-
- initPillSelector();
-
- // Modern Settings Container with Theme Support
- // Use the theme variable already declared at the top of createUI function
- const settingsContainer = document.createElement('div');
- settingsContainer.id = 'wplace-settings-container';
-
- // Apply theme-based styling
- const themeBackground = theme.primary
- ? `linear-gradient(135deg, ${theme.primary} 0%, ${theme.secondary || theme.primary} 100%)`
- : `linear-gradient(135deg, #667eea 0%, #764ba2 100%)`;
-
- settingsContainer.className = 'wplace-settings-container-base';
- // Apply theme-specific background
- settingsContainer.style.background = themeBackground;
- settingsContainer.style.cssText += `
- min-width: 420px;
- max-width: 480px;
- z-index: 99999;
- color: ${theme.text || 'white'};
- font-family: ${theme.fontFamily || "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif"};
- box-shadow: ${theme.boxShadow || '0 20px 40px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.1)'
- };
- backdrop-filter: ${theme.backdropFilter || 'blur(10px)'};
- overflow: hidden;
- animation: settings-slide-in 0.4s ease-out;
- ${theme.animations?.glow
- ? `
- box-shadow: ${theme.boxShadow || '0 20px 40px rgba(0,0,0,0.3)'},
- 0 0 30px ${theme.highlight || theme.neon || '#00ffff'};
- `
- : ''
- }
- `;
-
- // noinspection CssInvalidFunction
- settingsContainer.innerHTML = `
-
-
-
-
-
-
-
-
-
-
- Generator mode creates tokens automatically. Hybrid mode falls back to manual when generator fails. Manual mode only uses pixel placement.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${CONFIG.PAINTING_SPEED.MIN}
- ${CONFIG.PAINTING_SPEED.MAX}
-
-
-
-
-
-
-
- š² Random batch size between min and max values
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- š§± Block dimensions for block-based generation modes
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- const resizeContainer = document.createElement('div');
- resizeContainer.className = 'resize-container';
- resizeContainer.innerHTML = `
- ${Utils.t('resizeImage')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
100%
-
- Drag to pan ⢠Pinch to zoom ⢠Doubleātap to zoom
-
-
-
-
-
-
-
-
-
- Color Palette
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Advanced Color Matching
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- const resizeOverlay = document.createElement('div');
- resizeOverlay.className = 'resize-overlay';
-
- document.body.appendChild(container);
- document.body.appendChild(resizeOverlay);
- document.body.appendChild(resizeContainer);
- document.body.appendChild(statsContainer);
- document.body.appendChild(settingsContainer);
-
- // Show the main container after all elements are appended
- container.style.display = 'block';
-
- const uploadBtn = container.querySelector('#uploadBtn');
- const resizeBtn = container.querySelector('#resizeBtn');
- const selectPosBtn = container.querySelector('#selectPosBtn');
- const startBtn = container.querySelector('#startBtn');
- const stopBtn = container.querySelector('#stopBtn');
- const saveBtn = container.querySelector('#saveBtn');
- const loadBtn = container.querySelector('#loadBtn');
- const saveToFileBtn = container.querySelector('#saveToFileBtn');
- const loadFromFileBtn = container.querySelector('#loadFromFileBtn');
-
- container.querySelectorAll('.wplace-section-title').forEach((title) => {
- // Add a right-side arrow if it doesn't exist
- if (!title.querySelector('i.arrow')) {
- const arrow = document.createElement('i');
- arrow.className = 'fas fa-chevron-down arrow'; // FontAwesome down arrow
- title.appendChild(arrow);
- }
-
- // Click event to toggle collapse/expand of the section
- title.addEventListener('click', () => {
- const section = title.parentElement;
- section.classList.toggle('collapsed');
- });
- });
-
- // Disable load/upload buttons until initial setup is complete (startup only)
- if (loadBtn) {
- loadBtn.disabled = !state.initialSetupComplete;
- loadBtn.title = state.initialSetupComplete
- ? ''
- : 'š Waiting for initial setup to complete...';
- }
- if (loadFromFileBtn) {
- loadFromFileBtn.disabled = !state.initialSetupComplete;
- loadFromFileBtn.title = state.initialSetupComplete
- ? ''
- : 'š Waiting for initial setup to complete...';
- }
- if (uploadBtn) {
- uploadBtn.disabled = !state.initialSetupComplete;
- uploadBtn.title = state.initialSetupComplete
- ? ''
- : 'š Waiting for initial setup to complete...';
- }
-
- const minimizeBtn = container.querySelector('#minimizeBtn');
- const compactBtn = container.querySelector('#compactBtn');
- const statsBtn = container.querySelector('#statsBtn');
- const toggleOverlayBtn = container.querySelector('#toggleOverlayBtn');
- const statusText = container.querySelector('#statusText');
- const progressBar = container.querySelector('#progressBar');
- const statsArea = statsContainer.querySelector('#statsArea');
- const content = container.querySelector('.wplace-content');
- const closeStatsBtn = statsContainer.querySelector('#closeStatsBtn');
- const refreshChargesBtn = statsContainer.querySelector('#refreshChargesBtn');
- const cooldownSlider = container.querySelector('#cooldownSlider');
- const cooldownInput = container.querySelector('#cooldownInput');
- const cooldownDecrease = container.querySelector('#cooldownDecrease');
- const cooldownIncrease = container.querySelector('#cooldownIncrease');
- const cooldownValue = container.querySelector('#cooldownValue');
-
- if (!uploadBtn || !selectPosBtn || !startBtn || !stopBtn) {
- console.error('Some UI elements not found:', {
- uploadBtn: !!uploadBtn,
- selectPosBtn: !!selectPosBtn,
- startBtn: !!startBtn,
- stopBtn: !!stopBtn,
- });
- }
-
- if (!statsContainer || !statsArea || !closeStatsBtn) {
- // Note: base CSS now aligns with this layout: main panel at left:20px (width 280), stats at left:330px.
- }
-
- const header = container.querySelector('.wplace-header');
-
- makeDraggable(container);
-
- function makeDraggable(element) {
- let pos1 = 0,
- pos2 = 0,
- pos3 = 0,
- pos4 = 0;
- let isDragging = false;
- const header =
- element.querySelector('.wplace-header') || element.querySelector('.wplace-settings-header');
-
- if (!header) {
- console.warn('No draggable header found for element:', element);
- return;
- }
-
- header.onmousedown = dragMouseDown;
-
- function dragMouseDown(e) {
- if (e.target.closest('.wplace-header-btn') || e.target.closest('button')) return;
-
- e.preventDefault();
- isDragging = true;
-
- const rect = element.getBoundingClientRect();
-
- element.style.transform = 'none';
- element.style.top = rect.top + 'px';
- element.style.left = rect.left + 'px';
-
- pos3 = e.clientX;
- pos4 = e.clientY;
- element.classList.add('wplace-dragging');
- document.onmouseup = closeDragElement;
- document.onmousemove = elementDrag;
-
- document.body.style.userSelect = 'none';
- }
-
- function elementDrag(e) {
- if (!isDragging) return;
-
- e.preventDefault();
- pos1 = pos3 - e.clientX;
- pos2 = pos4 - e.clientY;
- pos3 = e.clientX;
- pos4 = e.clientY;
-
- let newTop = element.offsetTop - pos2;
- let newLeft = element.offsetLeft - pos1;
-
- const rect = element.getBoundingClientRect();
- const maxTop = window.innerHeight - rect.height;
- const maxLeft = window.innerWidth - rect.width;
-
- newTop = Math.max(0, Math.min(newTop, maxTop));
- newLeft = Math.max(0, Math.min(newLeft, maxLeft));
-
- element.style.top = newTop + 'px';
- element.style.left = newLeft + 'px';
- }
-
- function closeDragElement() {
- isDragging = false;
- element.classList.remove('wplace-dragging');
- document.onmouseup = null;
- document.onmousemove = null;
- document.body.style.userSelect = '';
- }
- }
-
- makeDraggable(statsContainer);
- makeDraggable(container);
-
- if (statsBtn && closeStatsBtn) {
- statsBtn.addEventListener('click', () => {
- const isVisible = statsContainer.style.display !== 'none';
- if (isVisible) {
- statsContainer.style.display = 'none';
- statsBtn.innerHTML = '';
- statsBtn.title = Utils.t('showStats');
- } else {
- statsContainer.style.display = 'block';
- statsBtn.innerHTML = '';
- statsBtn.title = Utils.t('hideStats');
- }
- });
-
- closeStatsBtn.addEventListener('click', () => {
- statsContainer.style.display = 'none';
- statsBtn.innerHTML = '';
- statsBtn.title = Utils.t('showStats');
- });
-
- if (refreshChargesBtn) {
- refreshChargesBtn.addEventListener('click', async () => {
- refreshChargesBtn.innerHTML = '';
- refreshChargesBtn.disabled = true;
-
- try {
- await updateStats();
- await updateCurrentAccountInList();
- } catch (error) {
- console.error('Error refreshing charges:', error);
- } finally {
- refreshChargesBtn.innerHTML = '';
- refreshChargesBtn.disabled = false;
- }
- });
- }
-
- // Account-related event handlers
- const autoSwapToggle = statsContainer.querySelector('#autoSwapToggle');
- const refreshAllAccountsBtn = statsContainer.querySelector('#refreshAllAccountsBtn');
-
- if (autoSwapToggle) {
- autoSwapToggle.checked = CONFIG.autoSwap;
- autoSwapToggle.addEventListener('change', (e) => {
- CONFIG.autoSwap = e.target.checked;
- console.log(`š Auto-swap ${CONFIG.autoSwap ? 'enabled' : 'disabled'}`);
-
- // Handle autoBuy toggle dependency
- const autoBuyToggle = statsContainer.querySelector('#autoBuyToggle');
- if (autoBuyToggle) {
- if (!CONFIG.autoSwap) {
- // If autoSwap is disabled, disable and uncheck autoBuy
- autoBuyToggle.disabled = true;
- autoBuyToggle.checked = false;
- CONFIG.autoBuyToggle = false;
- console.log(`š° Auto-buy disabled (requires auto-swap)`);
- } else {
- // If autoSwap is enabled, enable autoBuy toggle
- autoBuyToggle.disabled = false;
- }
- }
- });
- }
-
- // Auto Buy toggle logic
- const autoBuyToggle = statsContainer.querySelector('#autoBuyToggle');
- if (autoBuyToggle) {
- autoBuyToggle.checked = CONFIG.autoBuyToggle;
- autoBuyToggle.disabled = !CONFIG.autoSwap; // Disable if autoSwap is off
-
- autoBuyToggle.addEventListener('change', (e) => {
- CONFIG.autoBuyToggle = e.target.checked;
- console.log(`š° Auto-buy ${CONFIG.autoBuyToggle ? 'enabled' : 'disabled'}`);
- });
- }
-
- if (refreshAllAccountsBtn) {
- refreshAllAccountsBtn.addEventListener('click', fetchAllAccountDetails);
- }
- }
- if (statsContainer && statsBtn) {
- // Stats container starts visible by default - user clicks button to hide
- statsContainer.style.display = 'block';
- statsBtn.innerHTML = '';
- statsBtn.title = Utils.t('hideStats');
- }
-
- const settingsBtn = container.querySelector('#settingsBtn');
- const closeSettingsBtn = settingsContainer.querySelector('#closeSettingsBtn');
- const applySettingsBtn = settingsContainer.querySelector('#applySettingsBtn');
-
- if (settingsBtn && closeSettingsBtn && applySettingsBtn) {
- settingsBtn.addEventListener('click', () => {
- const isVisible = settingsContainer.classList.contains('show');
- if (isVisible) {
- settingsContainer.style.animation = 'settings-fade-out 0.3s ease-out forwards';
- settingsContainer.classList.remove('show');
- setTimeout(() => {
- settingsContainer.style.animation = '';
- }, 300);
- } else {
- settingsContainer.style.top = '50%';
- settingsContainer.style.left = '50%';
- settingsContainer.style.transform = 'translate(-50%, -50%)';
- settingsContainer.classList.add('show');
- settingsContainer.style.animation = 'settings-slide-in 0.4s ease-out';
- }
- });
-
- closeSettingsBtn.addEventListener('click', () => {
- settingsContainer.style.animation = 'settings-fade-out 0.3s ease-out forwards';
- settingsContainer.classList.remove('show');
- setTimeout(() => {
- settingsContainer.style.animation = '';
- settingsContainer.style.top = '50%';
- settingsContainer.style.left = '50%';
- settingsContainer.style.transform = 'translate(-50%, -50%)';
- }, 300);
- });
-
- applySettingsBtn.addEventListener('click', () => {
- // Sync advanced settings before save
- const colorAlgorithmSelect = document.getElementById('colorAlgorithmSelect');
- if (colorAlgorithmSelect) state.colorMatchingAlgorithm = colorAlgorithmSelect.value;
- const enableChromaPenaltyToggle = document.getElementById('enableChromaPenaltyToggle');
- if (enableChromaPenaltyToggle)
- state.enableChromaPenalty = enableChromaPenaltyToggle.checked;
- const chromaPenaltyWeightSlider = document.getElementById('chromaPenaltyWeightSlider');
- if (chromaPenaltyWeightSlider)
- state.chromaPenaltyWeight = parseFloat(chromaPenaltyWeightSlider.value) || 0.15;
- const transparencyThresholdInput = document.getElementById('transparencyThresholdInput');
- if (transparencyThresholdInput) {
- const v = parseInt(transparencyThresholdInput.value, 10);
- if (!isNaN(v) && v >= 0 && v <= 255) state.customTransparencyThreshold = v;
- }
- const whiteThresholdInput = document.getElementById('whiteThresholdInput');
- if (whiteThresholdInput) {
- const v = parseInt(whiteThresholdInput.value, 10);
- if (!isNaN(v) && v >= 200 && v <= 255) state.customWhiteThreshold = v;
- }
- // Update functional thresholds
- CONFIG.TRANSPARENCY_THRESHOLD = state.customTransparencyThreshold;
- CONFIG.WHITE_THRESHOLD = state.customWhiteThreshold;
- // Notifications
- const notifEnabledToggle = document.getElementById('notifEnabledToggle');
- const notifOnChargesToggle = document.getElementById('notifOnChargesToggle');
- const notifOnlyUnfocusedToggle = document.getElementById('notifOnlyUnfocusedToggle');
- const notifIntervalInput = document.getElementById('notifIntervalInput');
- if (notifEnabledToggle) state.notificationsEnabled = !!notifEnabledToggle.checked;
- if (notifOnChargesToggle) state.notifyOnChargesReached = !!notifOnChargesToggle.checked;
- if (notifOnlyUnfocusedToggle)
- state.notifyOnlyWhenUnfocused = !!notifOnlyUnfocusedToggle.checked;
- if (notifIntervalInput) {
- const v = parseInt(notifIntervalInput.value, 10);
- if (!isNaN(v) && v >= 1 && v <= 60) state.notificationIntervalMinutes = v;
- }
- saveBotSettings();
- Utils.showAlert(Utils.t('settingsSaved'), 'success');
- closeSettingsBtn.click();
- NotificationManager.syncFromState();
- });
-
- makeDraggable(settingsContainer);
-
- const tokenSourceSelect = settingsContainer.querySelector('#tokenSourceSelect');
- if (tokenSourceSelect) {
- tokenSourceSelect.addEventListener('change', (e) => {
- state.tokenSource = e.target.value;
- saveBotSettings();
- console.log(`š Token source changed to: ${state.tokenSource}`);
- const sourceNames = {
- generator: 'Automatic Generator',
- hybrid: 'Generator + Auto Fallback',
- manual: 'Manual Pixel Placement',
- };
- Utils.showAlert(
- Utils.t('tokenSourceSet', { source: sourceNames[state.tokenSource] }),
- 'success'
- );
- });
- }
-
- // Painting order controls
- const paintingOrderSelect = settingsContainer.querySelector('#paintingOrderSelect');
- if (paintingOrderSelect) {
- paintingOrderSelect.addEventListener('change', (e) => {
- state.paintingOrder = e.target.value;
- saveBotSettings();
- console.log(`šØ Painting order changed to: ${state.paintingOrder}`);
- const orderNames = {
- sequential: Utils.t('paintingOrderSequential'),
- 'color-by-color': Utils.t('paintingOrderColorByColor'),
- };
- Utils.showAlert(
- `Painting order set to: ${orderNames[state.paintingOrder]}`,
- 'success'
- );
- });
- }
-
- // Batch mode controls
- const batchModeSelect = settingsContainer.querySelector('#batchModeSelect');
- const normalBatchControls = settingsContainer.querySelector('#normalBatchControls');
- const randomBatchControls = settingsContainer.querySelector('#randomBatchControls');
- const randomBatchMin = settingsContainer.querySelector('#randomBatchMin');
- const randomBatchMax = settingsContainer.querySelector('#randomBatchMax');
-
- if (batchModeSelect) {
- batchModeSelect.addEventListener('change', (e) => {
- state.batchMode = e.target.value;
-
- // Switch between normal and random controls
- if (normalBatchControls && randomBatchControls) {
- if (e.target.value === 'random') {
- normalBatchControls.style.display = 'none';
- randomBatchControls.style.display = 'block';
- } else {
- normalBatchControls.style.display = 'block';
- randomBatchControls.style.display = 'none';
- }
- }
-
- saveBotSettings();
- console.log(`š¦ Batch mode changed to: ${state.batchMode}`);
- Utils.showAlert(
- Utils.t('batchModeSet', {
- mode:
- state.batchMode === 'random' ? Utils.t('randomRange') : Utils.t('normalFixedSize'),
- }),
- 'success'
- );
- });
- }
-
- if (randomBatchMin) {
- randomBatchMin.addEventListener('input', (e) => {
- const min = parseInt(e.target.value);
- if (min >= 1 && min <= 1000) {
- state.randomBatchMin = min;
- // Ensure min doesn't exceed max
- if (randomBatchMax && min > state.randomBatchMax) {
- state.randomBatchMax = min;
- randomBatchMax.value = min;
- }
- saveBotSettings();
- }
- });
- }
-
- if (randomBatchMax) {
- randomBatchMax.addEventListener('input', (e) => {
- const max = parseInt(e.target.value);
- if (max >= 1 && max <= 1000) {
- state.randomBatchMax = max;
- // Ensure max doesn't go below min
- if (randomBatchMin && max < state.randomBatchMin) {
- state.randomBatchMin = max;
- randomBatchMin.value = max;
- }
- saveBotSettings();
- }
- });
- }
-
- const languageSelect = settingsContainer.querySelector('#languageSelect');
- if (languageSelect) {
- languageSelect.addEventListener('change', async (e) => {
- const newLanguage = e.target.value;
- state.language = newLanguage;
- localStorage.setItem('wplace_language', newLanguage);
-
- // Load the new language translations
- await loadTranslations(newLanguage);
-
- setTimeout(() => {
- settingsContainer.style.display = 'none';
- createUI();
- }, 100);
- });
- }
-
- const themeSelect = settingsContainer.querySelector('#themeSelect');
- if (themeSelect) {
- themeSelect.addEventListener('change', (e) => {
- const newTheme = e.target.value;
- switchTheme(newTheme);
- });
- }
-
- const overlayOpacitySlider = settingsContainer.querySelector('#overlayOpacitySlider');
- const overlayOpacityValue = settingsContainer.querySelector('#overlayOpacityValue');
- const enableBlueMarbleToggle = settingsContainer.querySelector('#enableBlueMarbleToggle');
- const settingsPaintWhiteToggle = settingsContainer.querySelector('#settingsPaintWhiteToggle');
- const settingsPaintTransparentToggle = settingsContainer.querySelector(
- '#settingsPaintTransparentToggle'
- );
-
- if (overlayOpacitySlider && overlayOpacityValue) {
- const updateOpacity = (newValue) => {
- const opacity = parseFloat(newValue);
- state.overlayOpacity = opacity;
- overlayOpacitySlider.value = opacity;
- overlayOpacityValue.textContent = `${Math.round(opacity * 100)}%`;
- };
-
- overlayOpacitySlider.addEventListener('input', (e) => {
- updateOpacity(e.target.value);
- });
-
- // Add scroll-to-adjust for overlay opacity slider
- Utils.createScrollToAdjust(overlayOpacitySlider, updateOpacity, 0, 1, 0.05);
- }
-
- if (settingsPaintWhiteToggle) {
- settingsPaintWhiteToggle.checked = state.paintWhitePixels;
- settingsPaintWhiteToggle.addEventListener('change', (e) => {
- state.paintWhitePixels = e.target.checked;
- saveBotSettings();
- console.log(`šØ Paint white pixels: ${state.paintWhitePixels ? 'ON' : 'OFF'}`);
- const statusText = state.paintWhitePixels
- ? 'White pixels in the template will be painted'
- : 'White pixels will be skipped';
- Utils.showAlert(statusText, 'success');
- });
- }
-
- if (settingsPaintTransparentToggle) {
- settingsPaintTransparentToggle.checked = state.paintTransparentPixels;
- settingsPaintTransparentToggle.addEventListener('change', (e) => {
- state.paintTransparentPixels = e.target.checked;
- saveBotSettings();
- console.log(
- `šØ Paint transparent pixels: ${state.paintTransparentPixels ? 'ON' : 'OFF'}`
- );
- const statusText = state.paintTransparentPixels
- ? 'Transparent pixels in the template will be painted with the closest available color'
- : 'Transparent pixels will be skipped';
- Utils.showAlert(statusText, 'success');
- });
- }
-
- // Speed controls - both slider and input
- const speedSlider = settingsContainer.querySelector('#speedSlider');
- const speedInput = settingsContainer.querySelector('#speedInput');
- const speedDecrease = settingsContainer.querySelector('#speedDecrease');
- const speedIncrease = settingsContainer.querySelector('#speedIncrease');
- const speedValue = settingsContainer.querySelector('#speedValue');
-
- if (speedSlider && speedInput && speedValue && speedDecrease && speedIncrease) {
- const updateSpeed = (newValue) => {
- const speed = Math.max(CONFIG.PAINTING_SPEED.MIN, Math.min(CONFIG.PAINTING_SPEED.MAX, parseInt(newValue)));
- state.paintingSpeed = speed;
-
- // Update both controls (value shows in input, label shows unit only)
- speedSlider.value = speed;
- speedInput.value = speed;
- speedValue.textContent = `pixels`;
-
- saveBotSettings();
- };
-
- // Slider event listener
- speedSlider.addEventListener('input', (e) => {
- updateSpeed(e.target.value);
- });
-
- // Number input event listener
- speedInput.addEventListener('input', (e) => {
- updateSpeed(e.target.value);
- });
-
- // Decrease button
- speedDecrease.addEventListener('click', () => {
- updateSpeed(parseInt(speedInput.value) - 1);
- });
-
- // Increase button
- speedIncrease.addEventListener('click', () => {
- updateSpeed(parseInt(speedInput.value) + 1);
- });
-
- // Add scroll-to-adjust for speed slider
- Utils.createScrollToAdjust(speedSlider, updateSpeed, CONFIG.PAINTING_SPEED.MIN, CONFIG.PAINTING_SPEED.MAX, 1);
- }
-
- if (enableBlueMarbleToggle) {
- enableBlueMarbleToggle.addEventListener('click', async () => {
- state.blueMarbleEnabled = enableBlueMarbleToggle.checked;
- if (state.imageLoaded && overlayManager.imageBitmap) {
- Utils.showAlert(Utils.t('reprocessingOverlay'), 'info');
- await overlayManager.processImageIntoChunks();
- Utils.showAlert(Utils.t('overlayUpdated'), 'success');
- }
- });
- }
-
- // Speed control toggle
- const enableSpeedToggle = settingsContainer.querySelector('#enableSpeedToggle');
- if (enableSpeedToggle) {
- enableSpeedToggle.addEventListener('change', (e) => {
- CONFIG.PAINTING_SPEED_ENABLED = e.target.checked;
- saveBotSettings();
- console.log(`ā” Batch speed control: ${CONFIG.PAINTING_SPEED_ENABLED ? 'ON' : 'OFF'}`);
- const statusText = CONFIG.PAINTING_SPEED_ENABLED
- ? 'Batch speed control enabled - will use configured batch size'
- : 'Batch speed control disabled - will use all available charges immediately';
- Utils.showAlert(statusText, 'success');
- });
- }
-
- // (Advanced color listeners moved outside to work with resize dialog)
- // (Advanced color listeners moved outside to work with resize dialog)
- // Notifications listeners
- const notifPermBtn = settingsContainer.querySelector('#notifRequestPermBtn');
- const notifTestBtn = settingsContainer.querySelector('#notifTestBtn');
- if (notifPermBtn) {
- notifPermBtn.addEventListener('click', async () => {
- const perm = await NotificationManager.requestPermission();
- if (perm === 'granted') Utils.showAlert(Utils.t('notificationsEnabled'), 'success');
- else Utils.showAlert(Utils.t('notificationsPermissionDenied'), 'warning');
- });
- }
- if (notifTestBtn) {
- notifTestBtn.addEventListener('click', () => {
- NotificationManager.notify(
- Utils.t('testNotificationTitle'),
- Utils.t('testNotificationMessage'),
- 'wplace-notify-test',
- true
- );
- });
- }
- }
-
- const widthSlider = resizeContainer.querySelector('#widthSlider');
- const heightSlider = resizeContainer.querySelector('#heightSlider');
- const widthValue = resizeContainer.querySelector('#widthValue');
- const heightValue = resizeContainer.querySelector('#heightValue');
- const keepAspect = resizeContainer.querySelector('#keepAspect');
- const paintWhiteToggle = resizeContainer.querySelector('#paintWhiteToggle');
- const paintTransparentToggle = resizeContainer.querySelector('#paintTransparentToggle');
- const zoomSlider = resizeContainer.querySelector('#zoomSlider');
- const zoomValue = resizeContainer.querySelector('#zoomValue');
- const zoomInBtn = resizeContainer.querySelector('#zoomInBtn');
- const zoomOutBtn = resizeContainer.querySelector('#zoomOutBtn');
- const zoomFitBtn = resizeContainer.querySelector('#zoomFitBtn');
- const zoomActualBtn = resizeContainer.querySelector('#zoomActualBtn');
- const panModeBtn = resizeContainer.querySelector('#panModeBtn');
- const panStage = resizeContainer.querySelector('#resizePanStage');
- const canvasStack = resizeContainer.querySelector('#resizeCanvasStack');
- const baseCanvas = resizeContainer.querySelector('#resizeCanvas');
- const maskCanvas = resizeContainer.querySelector('#maskCanvas');
- const baseCtx = baseCanvas.getContext('2d');
- const maskCtx = maskCanvas.getContext('2d');
- const confirmResize = resizeContainer.querySelector('#confirmResize');
- const cancelResize = resizeContainer.querySelector('#cancelResize');
- const editImageBtn = resizeContainer.querySelector('#editImageBtn');
- const downloadPreviewBtn = resizeContainer.querySelector('#downloadPreviewBtn');
- const clearIgnoredBtn = resizeContainer.querySelector('#clearIgnoredBtn');
-
- // Coordinate generation controls with smart visibility
- const coordinateModeSelect = settingsContainer.querySelector('#coordinateModeSelect');
- const coordinateDirectionSelect = settingsContainer.querySelector('#coordinateDirectionSelect');
- const coordinateSnakeToggle = settingsContainer.querySelector('#coordinateSnakeToggle');
- const directionControls = settingsContainer.querySelector('#directionControls');
- const snakeControls = settingsContainer.querySelector('#snakeControls');
- const blockControls = settingsContainer.querySelector('#blockControls');
- const blockWidthInput = settingsContainer.querySelector('#blockWidthInput');
- const blockHeightInput = settingsContainer.querySelector('#blockHeightInput');
- const paintUnavailablePixelsToggle = settingsContainer.querySelector(
- '#paintUnavailablePixelsToggle'
- );
-
- if (paintUnavailablePixelsToggle) {
- paintUnavailablePixelsToggle.checked = state.paintUnavailablePixels;
- paintUnavailablePixelsToggle.addEventListener('change', (e) => {
- state.paintUnavailablePixels = e.target.checked;
- saveBotSettings();
- console.log(`šØ Paint unavailable colors: ${state.paintUnavailablePixels ? 'ON' : 'OFF'}`);
- const statusText = state.paintUnavailablePixels
- ? 'Unavailable template colors will be painted with the closest available color'
- : 'Unavailable template colors will be skipped';
- Utils.showAlert(statusText, 'success');
- });
- }
- if (coordinateModeSelect) {
- coordinateModeSelect.value = state.coordinateMode;
- coordinateModeSelect.addEventListener('change', (e) => {
- state.coordinateMode = e.target.value;
- Utils.updateCoordinateUI({
- mode: state.coordinateMode,
- directionControls,
- snakeControls,
- blockControls,
- });
- saveBotSettings();
- console.log(`š Coordinate mode changed to: ${state.coordinateMode}`);
- Utils.showAlert(`Coordinate mode set to: ${state.coordinateMode}`, 'success');
- });
- }
-
- if (coordinateDirectionSelect) {
- coordinateDirectionSelect.value = state.coordinateDirection;
- coordinateDirectionSelect.addEventListener('change', (e) => {
- state.coordinateDirection = e.target.value;
- saveBotSettings();
- console.log(`š§ Coordinate direction changed to: ${state.coordinateDirection}`);
- Utils.showAlert(`Coordinate direction set to: ${state.coordinateDirection}`, 'success');
- });
- }
-
- if (coordinateSnakeToggle) {
- coordinateSnakeToggle.checked = state.coordinateSnake;
- coordinateSnakeToggle.addEventListener('change', (e) => {
- state.coordinateSnake = e.target.checked;
- saveBotSettings();
- console.log(`š Snake pattern ${state.coordinateSnake ? 'enabled' : 'disabled'}`);
- Utils.showAlert(
- `Snake pattern ${state.coordinateSnake ? 'enabled' : 'disabled'}`,
- 'success'
- );
- });
- }
-
- if (blockWidthInput) {
- blockWidthInput.value = state.blockWidth;
- blockWidthInput.addEventListener('input', (e) => {
- const width = parseInt(e.target.value);
- if (width >= 1 && width <= 50) {
- state.blockWidth = width;
- saveBotSettings();
- }
- });
- }
-
- if (blockHeightInput) {
- blockHeightInput.value = state.blockHeight;
- blockHeightInput.addEventListener('change', (e) => {
- const height = parseInt(e.target.value);
- if (height >= 1 && height <= 50) {
- state.blockHeight = height;
- saveBotSettings();
- }
- });
- }
-
- if (compactBtn) {
- compactBtn.addEventListener('click', () => {
- container.classList.toggle('wplace-compact');
- const isCompact = container.classList.contains('wplace-compact');
-
- if (isCompact) {
- compactBtn.innerHTML = '';
- compactBtn.title = Utils.t('expandMode');
- } else {
- compactBtn.innerHTML = '';
- compactBtn.title = Utils.t('compactMode');
- }
- });
- }
-
- if (minimizeBtn) {
- minimizeBtn.addEventListener('click', () => {
- state.minimized = !state.minimized;
- if (state.minimized) {
- container.classList.add('wplace-minimized');
- content.classList.add('wplace-hidden');
- minimizeBtn.innerHTML = '';
- minimizeBtn.title = Utils.t('restore');
- } else {
- container.classList.remove('wplace-minimized');
- content.classList.remove('wplace-hidden');
- minimizeBtn.innerHTML = '';
- minimizeBtn.title = Utils.t('minimize');
- }
- saveBotSettings();
- });
- }
-
- if (toggleOverlayBtn) {
- toggleOverlayBtn.addEventListener('click', () => {
- const isEnabled = overlayManager.toggle();
- toggleOverlayBtn.classList.toggle('active', isEnabled);
- toggleOverlayBtn.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
- Utils.showAlert(isEnabled ? Utils.t('overlayEnabled') : Utils.t('overlayDisabled'), 'info');
- });
- }
-
- if (state.minimized) {
- container.classList.add('wplace-minimized');
- content.classList.add('wplace-hidden');
- if (minimizeBtn) {
- minimizeBtn.innerHTML = '';
- minimizeBtn.title = Utils.t('restore');
- }
- } else {
- container.classList.remove('wplace-minimized');
- content.classList.remove('wplace-hidden');
- if (minimizeBtn) {
- minimizeBtn.innerHTML = '';
- minimizeBtn.title = Utils.t('minimize');
- }
- }
-
- if (saveBtn) {
- saveBtn.addEventListener('click', () => {
- if (!state.imageLoaded) {
- Utils.showAlert(Utils.t('missingRequirements'), 'error');
- return;
- }
-
- const success = Utils.saveProgress();
- if (success) {
- updateUI('autoSaved', 'success');
- Utils.showAlert(Utils.t('autoSaved'), 'success');
- } else {
- Utils.showAlert(Utils.t('errorSavingProgress'), 'error');
- }
- });
- }
-
- if (loadBtn) {
- loadBtn.addEventListener('click', async () => {
- const savedData = Utils.loadProgress();
- if (!savedData) {
- updateUI('noSavedData', 'warning');
- Utils.showAlert(Utils.t('noSavedData'), 'warning');
- return;
- }
-
- // CRITICAL FIX: If save file contains complete data, bypass initial setup check
- // This matches old version behavior where save files could be loaded immediately
- const hasCompleteData = savedData.state && savedData.imageData &&
- savedData.state.availableColors &&
- savedData.state.availableColors.length > 0;
-
- if (!state.initialSetupComplete && !hasCompleteData) {
- Utils.showAlert(Utils.t('pleaseWaitInitialSetup'), 'warning');
- return;
- }
-
- const confirmLoad = confirm(
- `${Utils.t('savedDataFound')}\n\n` +
- `Saved: ${new Date(savedData.timestamp).toLocaleString()}\n` +
- `Progress: ${savedData.state.paintedPixels}/${savedData.state.totalPixels} pixels`
- );
-
- if (confirmLoad) {
- const success = Utils.restoreProgress(savedData);
- if (success) {
- // CRITICAL FIX: Set initial setup complete if save had complete data
- if (hasCompleteData) {
- state.initialSetupComplete = true;
- }
-
- updateUI('dataLoaded', 'success');
- Utils.showAlert(Utils.t('dataLoaded'), 'success');
- updateDataButtons();
-
- // CRITICAL FIX: Force immediate stats update after save loading
- await updateStats();
-
- // Force stats display refresh by updating the stats container
- const statsContainer = document.getElementById('wplace-stats-container');
- if (statsContainer && typeof updateStats === 'function') {
- // Trigger a forced visual refresh of the stats
- setTimeout(async () => {
- await updateStats();
- }, 100);
- }
-
- // Restore overlay if image data was loaded from localStorage
- Utils.restoreOverlayFromData().catch((error) => {
- console.error('Failed to restore overlay from localStorage:', error);
- });
-
- if (!state.colorsChecked) {
- uploadBtn.disabled = false;
- const loadExtractedBtn = document.getElementById('loadExtractedBtn');
- if (loadExtractedBtn) loadExtractedBtn.disabled = false;
- } else {
- uploadBtn.disabled = false;
- const loadExtractedBtn = document.getElementById('loadExtractedBtn');
- if (loadExtractedBtn) loadExtractedBtn.disabled = false;
- selectPosBtn.disabled = false;
- }
-
- if (state.imageLoaded && state.startPosition && state.region && state.colorsChecked) {
- startBtn.disabled = false;
- }
- } else {
- Utils.showAlert(Utils.t('errorLoadingProgress'), 'error');
- }
- }
- });
- }
-
- if (saveToFileBtn) {
- saveToFileBtn.addEventListener('click', () => {
- const success = Utils.saveProgressToFile();
- if (success) {
- updateUI('fileSaved', 'success');
- Utils.showAlert(Utils.t('fileSaved'), 'success');
- } else {
- Utils.showAlert(Utils.t('fileError'), 'error');
- }
- });
- }
-
- if (loadFromFileBtn) {
- loadFromFileBtn.addEventListener('click', async () => {
- console.log('š [DEBUG] Load from file button clicked');
- console.log('š [DEBUG] Initial setup complete:', state.initialSetupComplete);
-
- try {
- // First check if we can load the file - this will tell us if it has complete data
- console.log('š [DEBUG] Starting file upload...');
- const fileData = await Utils.createFileUploader();
-
- console.log('š [DEBUG] File data received:', !!fileData);
- console.log('š [DEBUG] File data keys:', fileData ? Object.keys(fileData) : 'N/A');
-
- if (!fileData || !fileData.state) {
- console.error('ā [DEBUG] Invalid file data or missing state');
- console.log('š [DEBUG] FileData:', fileData);
- Utils.showAlert(Utils.t('invalidFileFormat'), 'error');
- return;
- }
-
- // CRITICAL FIX: If file contains complete data, bypass initial setup check
- const hasCompleteData = fileData.state && fileData.imageData &&
- fileData.state.availableColors &&
- fileData.state.availableColors.length > 0;
-
- console.log('š [DEBUG] Has complete data check:');
- console.log(' - fileData.state:', !!fileData.state);
- console.log(' - fileData.imageData:', !!fileData.imageData);
- console.log(' - fileData.state.availableColors:', !!fileData.state.availableColors);
- console.log(' - availableColors length:', fileData.state.availableColors?.length);
- console.log(' - hasCompleteData result:', hasCompleteData);
-
- if (!state.initialSetupComplete && !hasCompleteData) {
- console.log('ā ļø [DEBUG] Setup not complete and no complete data, showing warning');
- Utils.showAlert(Utils.t('pleaseWaitFileSetup'), 'warning');
- return;
- }
-
- console.log('š [DEBUG] Calling restoreProgress...');
- const success = Utils.restoreProgress(fileData);
- console.log('š [DEBUG] RestoreProgress result:', success);
-
- if (success) {
- console.log('ā
[DEBUG] File loaded successfully');
-
- // CRITICAL FIX: Set initial setup complete if file had complete data
- if (hasCompleteData) {
- console.log('š [DEBUG] Setting initialSetupComplete to true due to complete data');
- state.initialSetupComplete = true;
- }
-
- updateUI('fileLoaded', 'success');
- Utils.showAlert(Utils.t('fileLoaded'), 'success');
- updateDataButtons();
-
- console.log('š [DEBUG] Updating stats...');
- await updateStats();
-
- // Restore overlay if image data was loaded from file
- console.log('š [DEBUG] Attempting to restore overlay...');
- await Utils.restoreOverlayFromData().catch((error) => {
- console.error('ā [DEBUG] Failed to restore overlay from file:', error);
- });
-
- console.log('š [DEBUG] Updating button states after load...');
- console.log(' - state.colorsChecked:', state.colorsChecked);
- console.log(' - state.imageLoaded:', state.imageLoaded);
- console.log(' - state.startPosition:', !!state.startPosition);
- console.log(' - state.region:', !!state.region);
-
- if (state.colorsChecked) {
- console.log('š [DEBUG] Enabling buttons due to colorsChecked');
- uploadBtn.disabled = false;
- selectPosBtn.disabled = false;
- resizeBtn.disabled = false;
- } else {
- console.log('š [DEBUG] Only enabling upload button');
- uploadBtn.disabled = false;
- }
-
- if (state.imageLoaded && state.startPosition && state.region && state.colorsChecked) {
- console.log('š [DEBUG] All conditions met, enabling start button');
- startBtn.disabled = false;
- } else {
- console.log('ā ļø [DEBUG] Start button requirements not met');
- }
- } else {
- console.error('ā [DEBUG] RestoreProgress failed');
- Utils.showAlert('File loading failed - check console for details', 'error');
- }
- } catch (error) {
- console.error('ā [DEBUG] Error in file load process:', error);
- console.error('ā [DEBUG] Error stack:', error.stack);
-
- if (error.message === 'Invalid JSON file') {
- console.error('ā [DEBUG] Invalid JSON file detected');
- Utils.showAlert(Utils.t('invalidFileFormat'), 'error');
- } else {
- console.error('ā [DEBUG] General file error:', error.message);
- Utils.showAlert(`File error: ${error.message}`, 'error');
- }
- }
- });
- }
-
- updateUI = (messageKey, type = 'default', params = {}, silent = false) => {
- const message = Utils.t(messageKey, params);
- statusText.textContent = message;
- statusText.className = `wplace-status status-${type}`;
-
- if (!silent) {
- // Trigger animation only when silent = false
- statusText.style.animation = 'none';
- void statusText.offsetWidth; // trick to restart the animation
- statusText.style.animation = 'slide-in 0.3s ease-out';
- }
- };
-
- function updateChargeStatsDisplay(intervalMs) {
- const currentChargesEl = document.getElementById('wplace-stat-charges-value');
- const fullChargeEl = document.getElementById('wplace-stat-fullcharge-value');
- if (!fullChargeEl && !currentChargesEl) return;
- if (!state.fullChargeData) {
- fullChargeEl.textContent = '--:--:--';
- return;
- }
-
- const { current, max, cooldownMs, startTime, spentSinceShot } = state.fullChargeData;
- const elapsed = Date.now() - startTime;
-
- // total charges including elapsed time and spent during painting since snapshot
- const chargesGained = elapsed / cooldownMs;
- const rawCharges = current + chargesGained - spentSinceShot;
- const cappedCharges = Math.min(rawCharges, max);
-
- // rounding with 0.95 threshold
- let displayCharges;
- const fraction = cappedCharges - Math.floor(cappedCharges);
- if (fraction >= 0.95) {
- displayCharges = Math.ceil(cappedCharges);
- } else {
- displayCharges = Math.floor(cappedCharges);
- }
-
- state.displayCharges = Math.max(0, displayCharges);
- state.preciseCurrentCharges = cappedCharges;
-
- const remainingMs = getMsToTargetCharges(cappedCharges, max, state.cooldown, intervalMs);
- const timeText = Utils.msToTimeText(remainingMs);
-
- if (currentChargesEl) {
- currentChargesEl.innerHTML = `${state.displayCharges} / ${state.maxCharges}`;
- }
-
- if (
- state.displayCharges < state.cooldownChargeThreshold &&
- !state.stopFlag &&
- state.running
- ) {
- updateChargesThresholdUI(intervalMs);
- }
-
- if (fullChargeEl) {
- if (state.displayCharges >= max) {
- fullChargeEl.innerHTML = `FULL`;
- } else {
- fullChargeEl.innerHTML = `
- ${timeText}
- `;
- }
- }
- }
-
- updateStats = async (isManualRefresh = false) => {
- const isFirstCheck = !state.fullChargeData?.startTime;
-
- const minUpdateInterval = 60_000;
- const maxUpdateInterval = 90_000;
- const randomUpdateThreshold =
- minUpdateInterval + Math.random() * (maxUpdateInterval - minUpdateInterval);
- const timeSinceLastUpdate = Date.now() - (state.fullChargeData?.startTime || 0);
- const isTimeToUpdate = timeSinceLastUpdate >= randomUpdateThreshold;
-
- const shouldCallApi = isFirstCheck || isTimeToUpdate;
-
- const { charges, max, cooldown } = await WPlaceService.getCharges();
- state.displayCharges = Math.floor(charges);
- state.preciseCurrentCharges = charges;
- state.cooldown = cooldown;
- state.maxCharges = Math.floor(max) > 1 ? Math.floor(max) : state.maxCharges;
-
- state.fullChargeData = {
- current: charges,
- max: max,
- cooldownMs: cooldown,
- startTime: Date.now(),
- spentSinceShot: 0,
- };
- // Evaluate notifications every time we refresh server-side charges
- NotificationManager.maybeNotifyChargesReached();
-
- if (state.fullChargeInterval) {
- clearInterval(state.fullChargeInterval);
- state.fullChargeInterval = null;
- }
- const intervalMs = 1000;
- state.fullChargeInterval = setInterval(
- () => updateChargeStatsDisplay(intervalMs),
- intervalMs
- );
-
- if (cooldownSlider && cooldownSlider.max !== state.maxCharges) {
- cooldownSlider.max = state.maxCharges;
- }
- if (cooldownInput && cooldownInput.max !== state.maxCharges) {
- cooldownInput.max = state.maxCharges;
- }
-
- // // Update current account charges in the account list
- // await updateCurrentAccountInList();
-
- let imageStatsHTML = '';
- if (state.imageLoaded) {
- const progress =
- state.totalPixels > 0 ? Math.round((state.paintedPixels / state.totalPixels) * 100) : 0;
- const remainingPixels = state.totalPixels - state.paintedPixels;
-
- // Updated estimation calculation for new cooldown logic
- // Now we wait for cooldownChargeThreshold before processing pixels
-
- // Calculate batch size (with fallback if function not yet defined)
- let batchSize;
- try {
- batchSize = typeof calculateBatchSize === 'function' ? calculateBatchSize() : state.paintingSpeed || 5;
- } catch (e) {
- batchSize = state.paintingSpeed || 5;
- }
-
- // Ensure batchSize is valid and not zero
- batchSize = Math.max(1, Math.min(batchSize, remainingPixels));
- const batchesNeeded = Math.ceil(remainingPixels / batchSize);
-
- // Calculate time accounting for cooldown threshold behavior with safety checks
- let estimatedMs = 0;
- if (remainingPixels > 0 && Number.isFinite(state.cooldown) && state.cooldown > 0) {
- // Current charges available check
- const currentCharges = Math.max(0, state.displayCharges);
- const thresholdCharges = Math.max(1, state.cooldownChargeThreshold || 1);
-
- if (currentCharges < thresholdCharges) {
- // Need to wait for charges to reach threshold first
- const chargesNeeded = thresholdCharges - currentCharges;
- estimatedMs += chargesNeeded * state.cooldown;
- }
-
- // Calculate painting time more accurately
- // Each batch uses thresholdCharges, so calculate how many cycles we need
- const pixelsPerCycle = Math.min(batchSize, thresholdCharges);
- const cyclesNeeded = Math.ceil(remainingPixels / pixelsPerCycle);
-
- // Time between cycles (waiting for charges to regenerate)
- if (cyclesNeeded > 1) {
- const regenerationTime = (cyclesNeeded - 1) * thresholdCharges * state.cooldown;
- estimatedMs += regenerationTime;
- }
-
- // Add painting speed delay if enabled
- if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0) {
- const paintingDelay = remainingPixels * (1000 / state.paintingSpeed);
- estimatedMs += paintingDelay;
- }
- }
-
- // Safety check to prevent infinity or invalid values
- if (!Number.isFinite(estimatedMs) || estimatedMs < 0) {
- estimatedMs = 0;
- }
-
- state.estimatedTime = estimatedMs;
- progressBar.style.width = `${progress}%`;
-
- imageStatsHTML = `
-
-
${Utils.t('progress')}
-
${progress}%
-
-
-
${Utils.t(
- 'pixels'
- )}
-
${state.paintedPixels}/${state.totalPixels}
-
- `;
- }
-
- let colorSwatchesHTML = '';
- state.availableColors = state.availableColors.filter(
- (c) => c.name !== 'Unknown CoIor NaN' && c.id !== null
- );
-
- const availableColors = Utils.extractAvailableColors();
- const newCount = Array.isArray(availableColors) ? availableColors.length : 0;
-
- if (newCount === 0 && isManualRefresh) {
- Utils.showAlert(Utils.t('noColorsFound'), 'warning');
- } else if (newCount > 0 && state.availableColors.length < newCount) {
- const oldCount = state.availableColors.length;
-
- Utils.showAlert(
- Utils.t('colorsUpdated', {
- oldCount,
- newCount: newCount,
- diffCount: newCount - oldCount,
- }),
- 'success'
- );
- state.availableColors = availableColors;
- }
- if (state.colorsChecked) {
- colorSwatchesHTML = state.availableColors
- .map((color) => {
- const rgbString = `rgb(${color.rgb.join(',')})`;
- return ``;
- })
- .join('');
- }
-
- // Calculate multi-account statistics
- let totalAllCharges = 0;
- let totalMaxCharges = 0;
- const accounts = accountManager.getAllAccounts();
- if (accounts.length > 0) {
- // Use real-time charges for current account, stored data for others
- const currentAccount = accountManager.getCurrentAccount();
- totalAllCharges = accounts.reduce((sum, acc) => {
- if (currentAccount && acc.token === currentAccount.token) {
- // Use real-time data for current account
- return sum + Math.floor(state.displayCharges || state.preciseCurrentCharges || 0);
- } else {
- // Use stored data for other accounts
- return sum + Math.floor(acc.Charges || 0);
- }
- }, 0);
- totalMaxCharges = accounts.reduce((sum, acc) => {
- if (currentAccount && acc.token === currentAccount.token) {
- // Use real-time max charges for current account
- return sum + Math.floor(state.maxCharges || acc.Max || 0);
- } else {
- // Use stored data for other accounts
- return sum + Math.floor(acc.Max || 0);
- }
- }, 0);
- }
-
- statsArea.innerHTML = `
- ${imageStatsHTML}
- ${accounts.length > 0 ? `
-
-
- Total All Accounts Charges
-
-
- ${totalAllCharges}/${totalMaxCharges}
-
-
- ` : ''}
-
-
- ${Utils.t('charges')}
-
-
- ${state.displayCharges} / ${state.maxCharges}
-
-
-
-
- ${Utils.t('fullChargeIn')}
-
-
--:--:--
-
- ${state.colorsChecked
- ? `
-
-
${Utils.t(
- 'availableColors',
- { count: state.availableColors.length }
- )}
-
- ${colorSwatchesHTML}
-
-
- `
- : ''
- }
- `;
-
- // should be after statsArea.innerHTML = '...'. todo make full stats ui update partial
- updateChargeStatsDisplay(intervalMs);
- };
-
- updateDataButtons = () => {
- const hasImageData = state.imageLoaded && state.imageData;
- saveBtn.disabled = !hasImageData;
- saveToFileBtn.disabled = !hasImageData;
- };
-
- updateDataButtons();
-
- function showMoveArtworkPanel() {
- // Create move artwork control panel like status panels
- const movePanel = document.createElement('div');
- movePanel.id = 'moveArtworkPanel';
- movePanel.className = 'wplace-move-panel';
-
- movePanel.innerHTML = `
-
-
-
-
-
-
-
1px
-
-
-
-
-
- `;
-
- // No overlay - append directly to body like other panels
- document.body.appendChild(movePanel);
-
- // Make the panel draggable
- makeDraggable(movePanel);
-
- // Add event listeners for movement
- let currentMessageTimeout = null;
-
- function moveArtwork(deltaX, deltaY) {
- if (state.startPosition && state.region) {
- const newX = state.startPosition.x + deltaX;
- const newY = state.startPosition.y + deltaY;
-
- console.log(`š Moving artwork from (${state.startPosition.x}, ${state.startPosition.y}) to (${newX}, ${newY})`);
-
- state.startPosition.x = newX;
- state.startPosition.y = newY;
-
- // Clear any existing message timeout and alerts immediately
- if (currentMessageTimeout) {
- clearTimeout(currentMessageTimeout);
- currentMessageTimeout = null;
- }
- Utils.hideAlert(); // Hide current alert immediately
-
- // Update overlay position if available
- if (overlayManager) {
- overlayManager.setPosition(state.startPosition, state.region)
- .then(() => {
- console.log('ā
Overlay position updated');
- Utils.showAlert(`Moved to position (${newX}, ${newY})`, 'success');
- // Set new timeout for this message
- currentMessageTimeout = setTimeout(() => {
- Utils.hideAlert();
- currentMessageTimeout = null;
- }, 1500); // Message disappears after 1.5 seconds
- })
- .catch(err => {
- console.error('ā Failed to update overlay:', err);
- Utils.showAlert('Failed to update overlay position', 'error');
- });
- } else {
- Utils.showAlert(`Position updated to (${newX}, ${newY})`, 'success');
- // Set new timeout for this message
- currentMessageTimeout = setTimeout(() => {
- Utils.hideAlert();
- currentMessageTimeout = null;
- }, 1500);
- }
- } else {
- Utils.showAlert('Please select a position first before moving artwork', 'warning');
- }
- }
-
- document.getElementById('moveUp').addEventListener('click', () => moveArtwork(0, -1));
- document.getElementById('moveDown').addEventListener('click', () => moveArtwork(0, 1));
- document.getElementById('moveLeft').addEventListener('click', () => moveArtwork(-1, 0));
- document.getElementById('moveRight').addEventListener('click', () => moveArtwork(1, 0));
-
- // Close panel
- function closeMovePanel() {
- if (currentMessageTimeout) {
- clearTimeout(currentMessageTimeout);
- currentMessageTimeout = null;
- }
- Utils.hideAlert();
- document.body.removeChild(movePanel);
- }
-
- document.getElementById('closeMovePanel').addEventListener('click', closeMovePanel);
- }
-
- function showResizeDialog(processor) {
- let baseProcessor = processor;
- let width, height;
- if (state.originalImage?.dataUrl) {
- baseProcessor = new ImageProcessor(state.originalImage.dataUrl);
- width = state.originalImage.width;
- height = state.originalImage.height;
- } else {
- const dims = processor.getDimensions();
- width = dims.width;
- height = dims.height;
- }
- const aspectRatio = width / height;
-
- const rs = state.resizeSettings;
- widthSlider.max = width * 2;
- heightSlider.max = height * 2;
- let initialW = width;
- let initialH = height;
- if (
- rs &&
- Number.isFinite(rs.width) &&
- Number.isFinite(rs.height) &&
- rs.width > 0 &&
- rs.height > 0
- ) {
- initialW = rs.width;
- initialH = rs.height;
- }
- // Clamp to slider ranges
- initialW = Math.max(
- parseInt(widthSlider.min, 10) || 10,
- Math.min(initialW, parseInt(widthSlider.max, 10))
- );
- initialH = Math.max(
- parseInt(heightSlider.min, 10) || 10,
- Math.min(initialH, parseInt(heightSlider.max, 10))
- );
- widthSlider.value = initialW;
- heightSlider.value = initialH;
- widthValue.textContent = initialW;
- heightValue.textContent = initialH;
- zoomSlider.value = 1;
- if (zoomValue) zoomValue.textContent = '100%';
- paintWhiteToggle.checked = state.paintWhitePixels;
- paintTransparentToggle.checked = state.paintTransparentPixels;
-
- let _previewTimer = null;
- let _previewJobId = 0;
- let _isDraggingSize = false;
- let _zoomLevel = 1;
- let _ditherWorkBuf = null;
- let _ditherEligibleBuf = null;
- const ensureDitherBuffers = (n) => {
- if (!_ditherWorkBuf || _ditherWorkBuf.length !== n * 3)
- _ditherWorkBuf = new Float32Array(n * 3);
- if (!_ditherEligibleBuf || _ditherEligibleBuf.length !== n)
- _ditherEligibleBuf = new Uint8Array(n);
- return { work: _ditherWorkBuf, eligible: _ditherEligibleBuf };
- };
- let _maskImageData = null;
- let _maskData = null;
- let _dirty = null;
- const _resetDirty = () => {
- _dirty = { minX: Infinity, minY: Infinity, maxX: -1, maxY: -1 };
- };
- const _markDirty = (x, y) => {
- if (!_dirty) _resetDirty();
- if (x < _dirty.minX) _dirty.minX = x;
- if (y < _dirty.minY) _dirty.minY = y;
- if (x > _dirty.maxX) _dirty.maxX = x;
- if (y > _dirty.maxY) _dirty.maxY = y;
- };
- const _flushDirty = () => {
- if (!_dirty || _dirty.maxX < _dirty.minX || _dirty.maxY < _dirty.minY) return;
- const x = Math.max(0, _dirty.minX);
- const y = Math.max(0, _dirty.minY);
- const w = Math.min(maskCanvas.width - x, _dirty.maxX - x + 1);
- const h = Math.min(maskCanvas.height - y, _dirty.maxY - y + 1);
- if (w > 0 && h > 0) maskCtx.putImageData(_maskImageData, 0, 0, x, y, w, h);
- _resetDirty();
- };
- const _ensureMaskOverlayBuffers = (w, h, rebuildFromMask = false) => {
- if (!_maskImageData || _maskImageData.width !== w || _maskImageData.height !== h) {
- _maskImageData = maskCtx.createImageData(w, h);
- _maskData = _maskImageData.data;
- rebuildFromMask = true;
- }
- if (rebuildFromMask) {
- const m = state.resizeIgnoreMask;
- const md = _maskData;
- md.fill(0);
- if (m) {
- for (let i = 0; i < m.length; i++)
- if (m[i]) {
- const p = i * 4;
- md[p] = 255;
- md[p + 1] = 0;
- md[p + 2] = 0;
- md[p + 3] = 150;
- }
- }
- maskCtx.putImageData(_maskImageData, 0, 0);
- _resetDirty();
- }
- };
- const ensureMaskSize = (w, h) => {
- if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
- state.resizeIgnoreMask = new Uint8Array(w * h);
- }
- baseCanvas.width = w;
- baseCanvas.height = h;
- maskCanvas.width = w;
- maskCanvas.height = h;
- maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
- // Ensure overlay buffers exist and rebuild from mask when dimensions change
- _ensureMaskOverlayBuffers(w, h, true);
- };
- _updateResizePreview = async () => {
- const jobId = ++_previewJobId;
- const newWidth = parseInt(widthSlider.value, 10);
- const newHeight = parseInt(heightSlider.value, 10);
- _zoomLevel = parseFloat(zoomSlider.value);
-
- widthValue.textContent = newWidth;
- heightValue.textContent = newHeight;
-
- ensureMaskSize(newWidth, newHeight);
- canvasStack.style.width = newWidth + 'px';
- canvasStack.style.height = newHeight + 'px';
- baseCtx.imageSmoothingEnabled = false;
-
- if (!state.availableColors || state.availableColors.length === 0) {
- if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
- await baseProcessor.load();
- }
- baseCtx.clearRect(0, 0, newWidth, newHeight);
- baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
- // Draw existing mask overlay buffer
- maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
- if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
- updateZoomLayout();
- return;
- }
-
- if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
- await baseProcessor.load();
- }
- baseCtx.clearRect(0, 0, newWidth, newHeight);
- baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
- const imgData = baseCtx.getImageData(0, 0, newWidth, newHeight);
- const data = imgData.data;
-
- const tThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
-
- const applyFSDither = () => {
- const w = newWidth,
- h = newHeight;
- const n = w * h;
- const { work, eligible } = ensureDitherBuffers(n);
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- const idx = y * w + x;
- const i4 = idx * 4;
- const r = data[i4],
- g = data[i4 + 1],
- b = data[i4 + 2],
- a = data[i4 + 3];
- const isEligible =
- (state.paintTransparentPixels || a >= tThresh) &&
- (state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
- eligible[idx] = isEligible ? 1 : 0;
- work[idx * 3] = r;
- work[idx * 3 + 1] = g;
- work[idx * 3 + 2] = b;
- if (!isEligible) {
- data[i4 + 3] = 0; // transparent in preview overlay
- }
- }
- }
-
- const diffuse = (nx, ny, er, eg, eb, factor) => {
- if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
- const nidx = ny * w + nx;
- if (!eligible[nidx]) return;
- const base = nidx * 3;
- work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
- work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
- work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
- };
-
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- const idx = y * w + x;
- if (!eligible[idx]) continue;
- const base = idx * 3;
- const r0 = work[base],
- g0 = work[base + 1],
- b0 = work[base + 2];
- const [nr, ng, nb] = Utils.findClosestPaletteColor(
- r0,
- g0,
- b0,
- state.activeColorPalette
- );
- const i4 = idx * 4;
- data[i4] = nr;
- data[i4 + 1] = ng;
- data[i4 + 2] = nb;
- data[i4 + 3] = 255;
-
- const er = r0 - nr;
- const eg = g0 - ng;
- const eb = b0 - nb;
-
- diffuse(x + 1, y, er, eg, eb, 7 / 16);
- diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
- diffuse(x, y + 1, er, eg, eb, 5 / 16);
- diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
- }
- }
- };
-
- // Skip expensive dithering while user is dragging sliders
- if (state.ditheringEnabled && !_isDraggingSize) {
- applyFSDither();
- } else {
- 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 (
- (!state.paintTransparentPixels && a < tThresh) ||
- (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
- ) {
- data[i + 3] = 0;
- continue;
- }
- const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
- data[i] = nr;
- data[i + 1] = ng;
- data[i + 2] = nb;
- data[i + 3] = 255;
- }
- }
-
- if (jobId !== _previewJobId) return;
- baseCtx.putImageData(imgData, 0, 0);
- maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
- if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
- updateZoomLayout();
- };
-
- const onWidthInput = () => {
- if (keepAspect.checked) {
- heightSlider.value = Math.round(parseInt(widthSlider.value, 10) / aspectRatio);
- }
- _updateResizePreview();
- const curW = parseInt(widthSlider.value, 10);
- const curH = parseInt(heightSlider.value, 10);
- state.resizeSettings = {
- baseWidth: width,
- baseHeight: height,
- width: curW,
- height: curH,
- };
- saveBotSettings();
- // Auto-fit after size changes
- const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
- if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
- };
-
- const onHeightInput = () => {
- if (keepAspect.checked) {
- widthSlider.value = Math.round(parseInt(heightSlider.value, 10) * aspectRatio);
- }
- _updateResizePreview();
- const curW = parseInt(widthSlider.value, 10);
- const curH = parseInt(heightSlider.value, 10);
- state.resizeSettings = {
- baseWidth: width,
- baseHeight: height,
- width: curW,
- height: curH,
- };
- saveBotSettings();
- // Auto-fit after size changes
- const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
- if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
- };
-
- paintWhiteToggle.onchange = (e) => {
- state.paintWhitePixels = e.target.checked;
- _updateResizePreview();
- saveBotSettings();
- };
-
- paintTransparentToggle.onchange = (e) => {
- state.paintTransparentPixels = e.target.checked;
- _updateResizePreview();
- saveBotSettings();
- };
-
- let panX = 0,
- panY = 0;
- const clampPan = () => {
- const wrapRect = panStage?.getBoundingClientRect() || {
- width: 0,
- height: 0,
- };
- const w = (baseCanvas.width || 1) * _zoomLevel;
- const h = (baseCanvas.height || 1) * _zoomLevel;
- if (w <= wrapRect.width) {
- panX = Math.floor((wrapRect.width - w) / 2);
- } else {
- const minX = wrapRect.width - w;
- panX = Math.min(0, Math.max(minX, panX));
- }
- if (h <= wrapRect.height) {
- panY = Math.floor((wrapRect.height - h) / 2);
- } else {
- const minY = wrapRect.height - h;
- panY = Math.min(0, Math.max(minY, panY));
- }
- };
- let _panRaf = 0;
- const applyPan = () => {
- if (_panRaf) return;
- _panRaf = requestAnimationFrame(() => {
- clampPan();
- canvasStack.style.transform = `translate3d(${Math.round(
- panX
- )}px, ${Math.round(panY)}px, 0) scale(${_zoomLevel})`;
- _panRaf = 0;
- });
- };
-
- const updateZoomLayout = () => {
- const w = baseCanvas.width || 1,
- h = baseCanvas.height || 1;
- baseCanvas.style.width = w + 'px';
- baseCanvas.style.height = h + 'px';
- maskCanvas.style.width = w + 'px';
- maskCanvas.style.height = h + 'px';
- canvasStack.style.width = w + 'px';
- canvasStack.style.height = h + 'px';
- applyPan();
- };
- const applyZoom = (z) => {
- _zoomLevel = Math.max(0.05, Math.min(20, z || 1));
- zoomSlider.value = _zoomLevel;
- updateZoomLayout();
- if (zoomValue) zoomValue.textContent = `${Math.round(_zoomLevel * 100)}%`;
- };
- zoomSlider.addEventListener('input', () => {
- applyZoom(parseFloat(zoomSlider.value));
- });
- if (zoomInBtn)
- zoomInBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) + 0.1));
- if (zoomOutBtn)
- zoomOutBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) - 0.1));
- const computeFitZoom = () => {
- const wrapRect = panStage?.getBoundingClientRect();
- if (!wrapRect) return 1;
- const w = baseCanvas.width || 1;
- const h = baseCanvas.height || 1;
- const margin = 10;
- const scaleX = (wrapRect.width - margin) / w;
- const scaleY = (wrapRect.height - margin) / h;
- return Math.max(0.05, Math.min(20, Math.min(scaleX, scaleY)));
- };
- if (zoomFitBtn)
- zoomFitBtn.addEventListener('click', () => {
- applyZoom(computeFitZoom());
- centerInView();
- });
- if (zoomActualBtn)
- zoomActualBtn.addEventListener('click', () => {
- applyZoom(1);
- centerInView();
- });
-
- const centerInView = () => {
- if (!panStage) return;
- const rect = panStage.getBoundingClientRect();
- const w = (baseCanvas.width || 1) * _zoomLevel;
- const h = (baseCanvas.height || 1) * _zoomLevel;
- panX = Math.floor((rect.width - w) / 2);
- panY = Math.floor((rect.height - h) / 2);
- applyPan();
- };
-
- let isPanning = false;
- let startX = 0,
- startY = 0,
- startPanX = 0,
- startPanY = 0;
- let allowPan = false; // Space key
- let panMode = false; // Explicit pan mode toggle for touch/one-button mice
- const isPanMouseButton = (e) => e.button === 1 || e.button === 2;
- const setCursor = (val) => {
- if (panStage) panStage.style.cursor = val;
- };
- const isPanActive = (e) => panMode || allowPan || isPanMouseButton(e);
- const updatePanModeBtn = () => {
- if (!panModeBtn) return;
- panModeBtn.classList.toggle('active', panMode);
- panModeBtn.setAttribute('aria-pressed', panMode ? 'true' : 'false');
- };
- if (panModeBtn) {
- updatePanModeBtn();
- panModeBtn.addEventListener('click', () => {
- panMode = !panMode;
- updatePanModeBtn();
- setCursor(panMode ? 'grab' : '');
- });
- }
- if (panStage) {
- panStage.addEventListener('contextmenu', (e) => {
- if (allowPan) e.preventDefault();
- });
- window.addEventListener('keydown', (e) => {
- if (e.code === 'Space') {
- allowPan = true;
- setCursor('grab');
- }
- });
- window.addEventListener('keyup', (e) => {
- if (e.code === 'Space') {
- allowPan = false;
- if (!isPanning) setCursor('');
- }
- });
- panStage.addEventListener('mousedown', (e) => {
- if (!isPanActive(e)) return;
- e.preventDefault();
- isPanning = true;
- startX = e.clientX;
- startY = e.clientY;
- startPanX = panX;
- startPanY = panY;
- setCursor('grabbing');
- });
- window.addEventListener('mousemove', (e) => {
- if (!isPanning) return;
- const dx = e.clientX - startX;
- const dy = e.clientY - startY;
- panX = startPanX + dx;
- panY = startPanY + dy;
- applyPan();
- });
- window.addEventListener('mouseup', () => {
- if (isPanning) {
- isPanning = false;
- setCursor(allowPan ? 'grab' : '');
- }
- });
- panStage.addEventListener(
- 'wheel',
- (e) => {
- if (!e.ctrlKey && !e.metaKey) return;
- e.preventDefault();
- const rect = panStage.getBoundingClientRect();
- const cx = e.clientX - rect.left - panX;
- const cy = e.clientY - rect.top - panY;
- const before = _zoomLevel;
- const step = Math.max(0.05, Math.min(0.5, Math.abs(e.deltaY) > 20 ? 0.2 : 0.1));
- const next = Math.max(0.05, Math.min(20, before + (e.deltaY > 0 ? -step : step)));
- if (next === before) return;
- const scale = next / before;
- panX = panX - cx * (scale - 1);
- panY = panY - cy * (scale - 1);
- applyZoom(next);
- },
- { passive: false }
- );
- let lastTouchDist = null;
- let touchStartTime = 0;
- let doubleTapTimer = null;
- panStage.addEventListener(
- 'touchstart',
- (e) => {
- if (e.touches.length === 1) {
- const t = e.touches[0];
- isPanning = true;
- startX = t.clientX;
- startY = t.clientY;
- startPanX = panX;
- startPanY = panY;
- setCursor('grabbing');
- const now = Date.now();
- if (now - touchStartTime < 300) {
- // double tap -> toggle 100%/fit
- const z = Math.abs(_zoomLevel - 1) < 0.01 ? computeFitZoom() : 1;
- applyZoom(z);
- centerInView();
- if (doubleTapTimer) clearTimeout(doubleTapTimer);
- } else {
- touchStartTime = now;
- doubleTapTimer = setTimeout(() => {
- doubleTapTimer = null;
- }, 320);
- }
- } else if (e.touches.length === 2) {
- // Pinch start
- const [a, b] = e.touches;
- lastTouchDist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
- }
- },
- { passive: true }
- );
- panStage.addEventListener(
- 'touchmove',
- (e) => {
- if (e.touches.length === 1 && isPanning) {
- const t = e.touches[0];
- const dx = t.clientX - startX;
- const dy = t.clientY - startY;
- panX = startPanX + dx;
- panY = startPanY + dy;
- applyPan();
- } else if (e.touches.length === 2 && lastTouchDist != null) {
- e.preventDefault();
- const [a, b] = e.touches;
- const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
- const rect = panStage.getBoundingClientRect();
- const centerX = (a.clientX + b.clientX) / 2 - rect.left - panX;
- const centerY = (a.clientY + b.clientY) / 2 - rect.top - panY;
- const before = _zoomLevel;
- const scale = dist / (lastTouchDist || dist);
- const next = Math.max(0.05, Math.min(20, before * scale));
- if (next !== before) {
- panX = panX - centerX * (next / before - 1);
- panY = panY - centerY * (next / before - 1);
- applyZoom(next);
- }
- lastTouchDist = dist;
- }
- },
- { passive: false }
- );
- panStage.addEventListener('touchend', () => {
- isPanning = false;
- lastTouchDist = null;
- setCursor(panMode || allowPan ? 'grab' : '');
- });
- }
- const schedulePreview = () => {
- if (_previewTimer) clearTimeout(_previewTimer);
- const run = () => {
- _previewTimer = null;
- _updateResizePreview();
- };
- if (window.requestIdleCallback) {
- _previewTimer = setTimeout(() => requestIdleCallback(run, { timeout: 150 }), 50);
- } else {
- _previewTimer = setTimeout(() => requestAnimationFrame(run), 50);
- }
- };
- // Track dragging to reduce work and skip dithering during drag
- const markDragStart = () => {
- _isDraggingSize = true;
- };
- const markDragEnd = () => {
- _isDraggingSize = false;
- schedulePreview();
- };
- widthSlider.addEventListener('pointerdown', markDragStart);
- heightSlider.addEventListener('pointerdown', markDragStart);
- widthSlider.addEventListener('pointerup', markDragEnd);
- heightSlider.addEventListener('pointerup', markDragEnd);
- widthSlider.addEventListener('input', () => {
- onWidthInput();
- schedulePreview();
- });
- heightSlider.addEventListener('input', () => {
- onHeightInput();
- schedulePreview();
- });
-
- // Mask painting UX: brush size, modes, row/column fills, and precise coords
- let draggingMask = false;
- let lastPaintX = -1,
- lastPaintY = -1;
- let brushSize = 1;
- let rowColSize = 1;
- let maskMode = 'ignore'; // 'ignore' | 'unignore' | 'toggle'
- const brushEl = resizeContainer.querySelector('#maskBrushSize');
- const brushValEl = resizeContainer.querySelector('#maskBrushSizeValue');
- const btnIgnore = resizeContainer.querySelector('#maskModeIgnore');
- const btnUnignore = resizeContainer.querySelector('#maskModeUnignore');
- const btnToggle = resizeContainer.querySelector('#maskModeToggle');
- const clearIgnoredBtnEl = resizeContainer.querySelector('#clearIgnoredBtn');
- const invertMaskBtn = resizeContainer.querySelector('#invertMaskBtn');
- const rowColSizeEl = resizeContainer.querySelector('#rowColSize');
- const rowColSizeValEl = resizeContainer.querySelector('#rowColSizeValue');
-
- const updateModeButtons = () => {
- const map = [
- [btnIgnore, 'ignore'],
- [btnUnignore, 'unignore'],
- [btnToggle, 'toggle'],
- ];
- for (const [el, m] of map) {
- if (!el) continue;
- const active = maskMode === m;
- el.classList.toggle('active', active);
- el.setAttribute('aria-pressed', active ? 'true' : 'false');
- }
- };
- const setMode = (mode) => {
- maskMode = mode;
- updateModeButtons();
- };
- if (brushEl && brushValEl) {
- brushEl.addEventListener('input', () => {
- brushSize = parseInt(brushEl.value, 10) || 1;
- brushValEl.textContent = brushSize;
- });
- brushValEl.textContent = brushEl.value;
- brushSize = parseInt(brushEl.value, 10) || 1;
- }
- if (rowColSizeEl && rowColSizeValEl) {
- rowColSizeEl.addEventListener('input', () => {
- rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
- rowColSizeValEl.textContent = rowColSize;
- });
- rowColSizeValEl.textContent = rowColSizeEl.value;
- rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
- }
- if (btnIgnore) btnIgnore.addEventListener('click', () => setMode('ignore'));
- if (btnUnignore) btnUnignore.addEventListener('click', () => setMode('unignore'));
- if (btnToggle) btnToggle.addEventListener('click', () => setMode('toggle'));
- // Initialize button state (default to toggle mode)
- updateModeButtons();
-
- const mapClientToPixel = (clientX, clientY) => {
- // Compute without rounding until final step to avoid drift at higher zoom
- const rect = baseCanvas.getBoundingClientRect();
- const scaleX = rect.width / baseCanvas.width;
- const scaleY = rect.height / baseCanvas.height;
- const dx = (clientX - rect.left) / scaleX;
- const dy = (clientY - rect.top) / scaleY;
- const x = Math.floor(dx);
- const y = Math.floor(dy);
- return { x, y };
- };
-
- const ensureMask = (w, h) => {
- if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
- state.resizeIgnoreMask = new Uint8Array(w * h);
- }
- };
-
- const paintCircle = (cx, cy, radius, value) => {
- const w = baseCanvas.width,
- h = baseCanvas.height;
- ensureMask(w, h);
- const r2 = radius * radius;
- for (let yy = cy - radius; yy <= cy + radius; yy++) {
- if (yy < 0 || yy >= h) continue;
- for (let xx = cx - radius; xx <= cx + radius; xx++) {
- if (xx < 0 || xx >= w) continue;
- const dx = xx - cx,
- dy = yy - cy;
- if (dx * dx + dy * dy <= r2) {
- const idx = yy * w + xx;
- let val = state.resizeIgnoreMask[idx];
- if (maskMode === 'toggle') {
- val = val ? 0 : 1;
- } else if (maskMode === 'ignore') {
- val = 1;
- } else {
- val = 0;
- }
- state.resizeIgnoreMask[idx] = val;
- if (_maskData) {
- const p = idx * 4;
- if (val) {
- _maskData[p] = 255;
- _maskData[p + 1] = 0;
- _maskData[p + 2] = 0;
- _maskData[p + 3] = 150;
- } else {
- _maskData[p] = 0;
- _maskData[p + 1] = 0;
- _maskData[p + 2] = 0;
- _maskData[p + 3] = 0;
- }
- _markDirty(xx, yy);
- }
- }
- }
- }
- };
-
- const paintRow = (y, value) => {
- const w = baseCanvas.width,
- h = baseCanvas.height;
- ensureMask(w, h);
- if (y < 0 || y >= h) return;
-
- // Paint multiple rows based on rowColSize
- const halfSize = Math.floor(rowColSize / 2);
- const startY = Math.max(0, y - halfSize);
- const endY = Math.min(h - 1, y + halfSize);
-
- for (let rowY = startY; rowY <= endY; rowY++) {
- for (let x = 0; x < w; x++) {
- const idx = rowY * w + x;
- let val = state.resizeIgnoreMask[idx];
- if (maskMode === 'toggle') {
- val = val ? 0 : 1;
- } else if (maskMode === 'ignore') {
- val = 1;
- } else {
- val = 0;
- }
- state.resizeIgnoreMask[idx] = val;
- if (_maskData) {
- const p = idx * 4;
- if (val) {
- _maskData[p] = 255;
- _maskData[p + 1] = 0;
- _maskData[p + 2] = 0;
- _maskData[p + 3] = 150;
- } else {
- _maskData[p] = 0;
- _maskData[p + 1] = 0;
- _maskData[p + 2] = 0;
- _maskData[p + 3] = 0;
- }
- }
- }
- if (_maskData) {
- _markDirty(0, rowY);
- _markDirty(w - 1, rowY);
- }
- }
- };
-
- const paintColumn = (x, value) => {
- const w = baseCanvas.width,
- h = baseCanvas.height;
- ensureMask(w, h);
- if (x < 0 || x >= w) return;
-
- // Paint multiple columns based on rowColSize
- const halfSize = Math.floor(rowColSize / 2);
- const startX = Math.max(0, x - halfSize);
- const endX = Math.min(w - 1, x + halfSize);
-
- for (let colX = startX; colX <= endX; colX++) {
- for (let y = 0; y < h; y++) {
- const idx = y * w + colX;
- let val = state.resizeIgnoreMask[idx];
- if (maskMode === 'toggle') {
- val = val ? 0 : 1;
- } else if (maskMode === 'ignore') {
- val = 1;
- } else {
- val = 0;
- }
- state.resizeIgnoreMask[idx] = val;
- if (_maskData) {
- const p = idx * 4;
- if (val) {
- _maskData[p] = 255;
- _maskData[p + 1] = 0;
- _maskData[p + 2] = 0;
- _maskData[p + 3] = 150;
- } else {
- _maskData[p] = 0;
- _maskData[p + 1] = 0;
- _maskData[p + 2] = 0;
- _maskData[p + 3] = 0;
- }
- }
- }
- if (_maskData) {
- _markDirty(colX, 0);
- _markDirty(colX, h - 1);
- }
- }
- };
-
- const redrawMaskOverlay = () => {
- // Only flush the dirty region; full rebuild happens on size change
- _flushDirty();
- };
-
- const handlePaint = (e) => {
- // Suppress painting while panning
- if ((e.buttons & 4) === 4 || (e.buttons & 2) === 2 || allowPan) return;
- const { x, y } = mapClientToPixel(e.clientX, e.clientY);
- const w = baseCanvas.width,
- h = baseCanvas.height;
- if (x < 0 || y < 0 || x >= w || y >= h) return;
- const radius = Math.max(1, Math.floor(brushSize / 2));
- if (e.shiftKey) {
- paintRow(y);
- } else if (e.altKey) {
- paintColumn(x);
- } else {
- paintCircle(x, y, radius);
- }
- lastPaintX = x;
- lastPaintY = y;
- redrawMaskOverlay();
- };
-
- maskCanvas.addEventListener('mousedown', (e) => {
- if (e.button === 1 || e.button === 2 || allowPan) return; // let pan handler manage
- draggingMask = true;
- handlePaint(e);
- });
- // Avoid hijacking touch gestures for panning/zooming
- maskCanvas.addEventListener(
- 'touchstart',
- (e) => {
- /* let panStage handle */
- },
- { passive: true }
- );
- maskCanvas.addEventListener(
- 'touchmove',
- (e) => {
- /* let panStage handle */
- },
- { passive: true }
- );
- maskCanvas.addEventListener(
- 'touchend',
- (e) => {
- /* let panStage handle */
- },
- { passive: true }
- );
- window.addEventListener('mousemove', (e) => {
- if (draggingMask) handlePaint(e);
- });
- window.addEventListener('mouseup', () => {
- if (draggingMask) {
- draggingMask = false;
- saveBotSettings();
- }
- });
-
- if (clearIgnoredBtnEl)
- clearIgnoredBtnEl.addEventListener('click', () => {
- const w = baseCanvas.width,
- h = baseCanvas.height;
- if (state.resizeIgnoreMask) state.resizeIgnoreMask.fill(0);
- _ensureMaskOverlayBuffers(w, h, true);
- _updateResizePreview();
- saveBotSettings();
- });
-
- if (invertMaskBtn)
- invertMaskBtn.addEventListener('click', () => {
- if (!state.resizeIgnoreMask) return;
- for (let i = 0; i < state.resizeIgnoreMask.length; i++)
- state.resizeIgnoreMask[i] = state.resizeIgnoreMask[i] ? 0 : 1;
- const w = baseCanvas.width,
- h = baseCanvas.height;
- _ensureMaskOverlayBuffers(w, h, true);
- _updateResizePreview();
- saveBotSettings();
- });
-
- confirmResize.onclick = async () => {
- const newWidth = parseInt(widthSlider.value, 10);
- const newHeight = parseInt(heightSlider.value, 10);
-
- // Generate the final paletted image data
- const tempCanvas = document.createElement('canvas');
- const tempCtx = tempCanvas.getContext('2d');
- tempCanvas.width = newWidth;
- tempCanvas.height = newHeight;
- tempCtx.imageSmoothingEnabled = false;
- if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
- await baseProcessor.load();
- }
- tempCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
- const imgData = tempCtx.getImageData(0, 0, newWidth, newHeight);
- const data = imgData.data;
- const tThresh2 = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
- let totalValidPixels = 0;
- const mask =
- state.resizeIgnoreMask && state.resizeIgnoreMask.length === newWidth * newHeight
- ? state.resizeIgnoreMask
- : null;
-
- const applyFSDitherFinal = async () => {
- const w = newWidth,
- h = newHeight;
- const n = w * h;
- const { work, eligible } = ensureDitherBuffers(n);
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- const idx = y * w + x;
- const i4 = idx * 4;
- const r = data[i4],
- g = data[i4 + 1],
- b = data[i4 + 2],
- a = data[i4 + 3];
- const masked = mask && mask[idx];
- const isEligible =
- !masked &&
- (state.paintTransparentPixels || a >= tThresh2) &&
- (state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
- eligible[idx] = isEligible ? 1 : 0;
- work[idx * 3] = r;
- work[idx * 3 + 1] = g;
- work[idx * 3 + 2] = b;
- if (!isEligible) {
- data[i4 + 3] = 0;
- }
- }
- // Yield to keep UI responsive
- if ((y & 15) === 0) await Promise.resolve();
- }
-
- const diffuse = (nx, ny, er, eg, eb, factor) => {
- if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
- const nidx = ny * w + nx;
- if (!eligible[nidx]) return;
- const base = nidx * 3;
- work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
- work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
- work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
- };
-
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- const idx = y * w + x;
- if (!eligible[idx]) continue;
- const base = idx * 3;
- const r0 = work[base],
- g0 = work[base + 1],
- b0 = work[base + 2];
- const [nr, ng, nb] = Utils.findClosestPaletteColor(
- r0,
- g0,
- b0,
- state.activeColorPalette
- );
- const i4 = idx * 4;
- data[i4] = nr;
- data[i4 + 1] = ng;
- data[i4 + 2] = nb;
- data[i4 + 3] = 255;
- totalValidPixels++;
-
- const er = r0 - nr;
- const eg = g0 - ng;
- const eb = b0 - nb;
-
- diffuse(x + 1, y, er, eg, eb, 7 / 16);
- diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
- diffuse(x, y + 1, er, eg, eb, 5 / 16);
- diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
- }
- // Yield every row to reduce jank
- await Promise.resolve();
- }
- };
-
- if (state.ditheringEnabled) {
- await applyFSDitherFinal();
- } else {
- 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];
- const masked = mask && mask[i >> 2];
- const isTransparent = (!state.paintTransparentPixels && a < tThresh2) || masked;
- const isWhiteAndSkipped = !state.paintWhitePixels && Utils.isWhitePixel(r, g, b);
- if (isTransparent || isWhiteAndSkipped) {
- data[i + 3] = 0; // overlay transparency
- continue;
- }
- totalValidPixels++;
- const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
- data[i] = nr;
- data[i + 1] = ng;
- data[i + 2] = nb;
- data[i + 3] = 255;
- }
- }
- tempCtx.putImageData(imgData, 0, 0);
-
- // Save the final pixel data for painting
- // Persist the paletted (and possibly dithered) pixels so painting uses the same output seen in overlay
- const palettedPixels = new Uint8ClampedArray(imgData.data);
- state.imageData.pixels = palettedPixels;
- state.imageData.width = newWidth;
- state.imageData.height = newHeight;
- state.imageData.totalPixels = totalValidPixels;
- state.totalPixels = totalValidPixels;
- state.paintedPixels = 0;
-
- state.resizeSettings = {
- baseWidth: width,
- baseHeight: height,
- width: newWidth,
- height: newHeight,
- };
- saveBotSettings();
-
- const finalImageBitmap = await createImageBitmap(tempCanvas);
- await overlayManager.setImage(finalImageBitmap);
- overlayManager.enable();
- toggleOverlayBtn.classList.add('active');
- toggleOverlayBtn.setAttribute('aria-pressed', 'true');
-
- // Keep state.imageData.processor as the original-based source; painting uses paletted pixels already stored
-
- await updateStats();
- updateUI('resizeSuccess', 'success', {
- width: newWidth,
- height: newHeight,
- });
- closeResizeDialog();
- };
-
- downloadPreviewBtn.onclick = () => {
- try {
- const w = baseCanvas.width,
- h = baseCanvas.height;
- const out = document.createElement('canvas');
- out.width = w;
- out.height = h;
- const octx = out.getContext('2d');
- octx.imageSmoothingEnabled = false;
- octx.drawImage(baseCanvas, 0, 0);
- octx.drawImage(maskCanvas, 0, 0);
- const link = document.createElement('a');
- link.download = 'wplace-preview.png';
- link.href = out.toDataURL();
- link.click();
- } catch (e) {
- console.warn('Failed to download preview:', e);
- }
- };
-
- cancelResize.onclick = closeResizeDialog;
-
- editImageBtn.onclick = () => {
- showEditPanel();
- };
-
- resizeOverlay.style.display = 'block';
- resizeContainer.style.display = 'block';
-
- // Reinitialize color palette with current available colors
- initializeColorPalette(resizeContainer);
-
- _updateResizePreview();
- _resizeDialogCleanup = () => {
- try {
- zoomSlider.replaceWith(zoomSlider.cloneNode(true));
- } catch { }
- try {
- if (zoomInBtn) zoomInBtn.replaceWith(zoomInBtn.cloneNode(true));
- } catch { }
- try {
- if (zoomOutBtn) zoomOutBtn.replaceWith(zoomOutBtn.cloneNode(true));
- } catch { }
- };
- setTimeout(() => {
- if (typeof computeFitZoom === 'function') {
- const z = computeFitZoom();
- if (!isNaN(z) && isFinite(z)) {
- applyZoom(z);
- centerInView();
- }
- } else {
- centerInView();
- }
- }, 0);
- }
-
- function closeResizeDialog() {
- try {
- if (typeof _resizeDialogCleanup === 'function') {
- _resizeDialogCleanup();
- }
- } catch { }
- resizeOverlay.style.display = 'none';
- resizeContainer.style.display = 'none';
- _updateResizePreview = () => { };
- try {
- if (typeof cancelAnimationFrame === 'function' && _panRaf) {
- cancelAnimationFrame(_panRaf);
- }
- } catch { }
- try {
- if (_previewTimer) {
- clearTimeout(_previewTimer);
- _previewTimer = null;
- }
- } catch { }
- _maskImageData = null;
- _maskData = null;
- _dirty = null;
- _ditherWorkBuf = null;
- _ditherEligibleBuf = null;
- _resizeDialogCleanup = null;
- }
-
- function showEditPanel() {
- try {
- // Validate that we have a valid base canvas
- if (!baseCanvas || !baseCanvas.width || !baseCanvas.height) {
- Utils.showAlert('No image available for editing. Please upload an image first.', 'error');
- return;
- }
-
- // Hide resize panel
- resizeContainer.style.display = 'none';
-
- // Create edit panel if it doesn't exist
- let editOverlay = document.getElementById('editOverlay');
- if (!editOverlay) {
- createEditPanel();
- editOverlay = document.getElementById('editOverlay');
- }
-
- // Get current image data from baseCanvas
- const imageData = baseCanvas.toDataURL();
-
- // Initialize edit panel with current image
- initializeEditPanel(imageData);
-
- // Show edit panel
- editOverlay.style.display = 'block';
-
- console.log('⨠Pixel Art Editor opened successfully');
- } catch (error) {
- console.error('Error opening pixel art editor:', error);
- Utils.showAlert('Failed to open pixel art editor. Please try again.', 'error');
- }
- }
-
- function createEditPanel() {
- const editOverlay = document.createElement('div');
- editOverlay.id = 'editOverlay';
- editOverlay.className = 'edit-overlay';
-
- editOverlay.innerHTML = `
-
-
-
-
-
-
-
-
- Position: (0, 0) | Color: #000000 | Zoom: 100%
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- document.body.appendChild(editOverlay);
-
- // Set up event handlers
- setupEditPanelEvents();
- }
-
- function setupEditPanelEvents() {
- const editBackBtn = document.getElementById('editBackBtn');
- const editApplyBtn = document.getElementById('editApplyBtn');
- const paintBrush = document.getElementById('paintBrush');
- const eraseTool = document.getElementById('eraseTool');
- const eyedropperTool = document.getElementById('eyedropperTool');
- const fillTool = document.getElementById('fillTool');
- const brushSize = document.getElementById('brushSize');
- const brushSizeValue = document.getElementById('brushSizeValue');
- const showGrid = document.getElementById('showGrid');
- const undoBtn = document.getElementById('undoBtn');
- const redoBtn = document.getElementById('redoBtn');
- const resetViewBtn = document.getElementById('resetViewBtn');
- const editZoomIn = document.getElementById('editZoomIn');
- const editZoomOut = document.getElementById('editZoomOut');
- const zoomSelect = document.getElementById('zoomSelect');
- const zoomFit = document.getElementById('zoomFit');
- const zoom100 = document.getElementById('zoom100');
- const minimapCanvas = document.getElementById('minimapCanvas');
-
- // Back to resize panel
- editBackBtn.onclick = () => {
- document.getElementById('editOverlay').style.display = 'none';
- resizeContainer.style.display = 'block';
- };
-
- // Apply changes
- editApplyBtn.onclick = () => {
- applyEditChanges();
- document.getElementById('editOverlay').style.display = 'none';
- resizeContainer.style.display = 'block';
- };
-
- // Tool selection
- paintBrush.onclick = () => {
- selectTool('paint');
- };
-
- eraseTool.onclick = () => {
- selectTool('erase');
- };
-
- eyedropperTool.onclick = () => {
- selectTool('eyedropper');
- };
-
- fillTool.onclick = () => {
- selectTool('fill');
- };
-
- // Grid toggle
- showGrid.onclick = () => {
- editState.showGrid = !editState.showGrid;
- showGrid.classList.toggle('active', editState.showGrid);
- redrawCanvas();
- };
-
- // Brush size
- brushSize.oninput = () => {
- brushSizeValue.textContent = brushSize.value;
- updateBrushSize(parseInt(brushSize.value));
- };
-
- // Undo/Redo
- undoBtn.onclick = () => undoEdit();
- redoBtn.onclick = () => redoEdit();
-
- // Reset view
- resetViewBtn.onclick = () => resetEditView();
-
- // Enhanced zoom controls
- editZoomIn.onclick = () => zoomIn();
- editZoomOut.onclick = () => zoomOut();
-
- zoomSelect.onchange = () => {
- const newZoom = parseFloat(zoomSelect.value);
- setZoom(newZoom);
- };
-
- zoomFit.onclick = () => fitToWindow();
- zoom100.onclick = () => setZoom(1);
-
- // Minimap navigation
- if (minimapCanvas) {
- minimapCanvas.onclick = (e) => navigateToMinimapPosition(e);
- }
- }
-
- const ZOOM_LEVELS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 32];
-
- let editState = {
- currentTool: 'paint',
- currentColor: '#000000',
- currentColorId: null,
- brushSize: 1,
- zoom: 1,
- panX: 0,
- panY: 0,
- undoStack: [],
- redoStack: [],
- isDrawing: false,
- isPanning: false,
- showGrid: false,
-
- mouseX: 0,
- mouseY: 0,
- canvasWidth: 0,
- canvasHeight: 0,
- updatePending: false,
- lastPaintPos: null,
- touchStart: null,
- lastTouchDistance: 0,
- lastTouchCenter: { x: 0, y: 0 }
- };
-
- function calculateOptimalPanelSize(imageWidth, imageHeight) {
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- // Reserve minimal space for UI elements for almost fullscreen experience
- const uiReserved = {
- header: 80,
- toolbar: 60,
- bottomBar: 120,
- padding: 40
- };
-
- const maxCanvasWidth = viewportWidth - uiReserved.padding;
- const maxCanvasHeight = viewportHeight - uiReserved.header - uiReserved.toolbar - uiReserved.bottomBar - uiReserved.padding;
-
- // Calculate optimal initial zoom to fit image
- const scaleX = maxCanvasWidth / imageWidth;
- const scaleY = maxCanvasHeight / imageHeight;
- const initialZoom = Math.min(scaleX, scaleY, 1); // Don't zoom in initially
-
- return {
- panelWidth: Math.min(viewportWidth * 0.95, imageWidth * initialZoom + uiReserved.colorPanel + uiReserved.padding),
- panelHeight: Math.min(viewportHeight * 0.95, imageHeight * initialZoom + uiReserved.toolbar + uiReserved.statusBar + uiReserved.canvasToolbar + uiReserved.padding),
- canvasWidth: Math.min(maxCanvasWidth, imageWidth * initialZoom),
- canvasHeight: Math.min(maxCanvasHeight, imageHeight * initialZoom),
- initialZoom: Math.max(0.1, initialZoom)
- };
- }
-
- function initializeEditPanel(imageData) {
- const editCanvas = document.getElementById('editCanvas');
- const ctx = editCanvas.getContext('2d');
-
- // Reset edit state
- editState.zoom = 1;
- editState.panX = 0;
- editState.panY = 0;
- editState.undoStack = [];
- editState.redoStack = [];
- editState.currentTool = 'paint';
- editState.brushSize = 1;
- editState.showGrid = false;
- editState.isPanning = false;
- editState.isDrawing = false;
- editState.lastPaintPos = null;
-
- // Set canvas size to match baseCanvas
- editCanvas.width = baseCanvas.width;
- editCanvas.height = baseCanvas.height;
- editState.canvasWidth = editCanvas.width;
- editState.canvasHeight = editCanvas.height;
-
- // Configure canvas context for pixel art
- ctx.imageSmoothingEnabled = false;
- ctx.webkitImageSmoothingEnabled = false;
- ctx.mozImageSmoothingEnabled = false;
- ctx.msImageSmoothingEnabled = false;
-
- // Calculate optimal panel size
- const panelSize = calculateOptimalPanelSize(editCanvas.width, editCanvas.height);
- const editContainer = document.querySelector('.edit-container');
- if (editContainer) {
- editContainer.style.width = panelSize.panelWidth + 'px';
- editContainer.style.height = panelSize.panelHeight + 'px';
- }
-
- // Setup canvas container
- setupCanvasContainer();
-
- // Add CSS checkerboard background to the wrapper element (not the canvas)
- // This allows users to see transparency without saving it to the template
- const canvasWrapper = document.getElementById('editCanvasWrapper');
- if (canvasWrapper) {
- canvasWrapper.style.background = `
- repeating-conic-gradient(#f0f0f0 0% 25%, #e0e0e0 0% 50%)
- 50% / 16px 16px
- `;
- }
-
- // Load image onto canvas
- const img = new Image();
- img.onload = () => {
- ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
- // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
- ctx.drawImage(img, 0, 0);
-
- // Always fit artwork to the visible area on start and center
- fitToWindow();
- centerCanvas();
-
- // Setup minimap
- setupMinimap();
-
- // Save initial state for undo
- saveEditState();
-
- // Set up canvas drawing events
- setupCanvasDrawing();
-
- // Initialize color palette
- initializeEditColorPalette();
-
- // Setup keyboard shortcuts
- setupKeyboardShortcuts();
-
- // Setup touch support
- setupTouchSupport();
-
- // Set initial tool
- selectTool('paint');
-
- // Update status bar
- updateStatusBar(0, 0);
-
- // Center canvas initially
- centerCanvas();
- };
- img.src = imageData;
- }
-
- // mapClientToCanvas function for coordinate mapping
- function mapClientToCanvas(clientX, clientY) {
- const canvas = document.getElementById('editCanvas');
- const wrapper = document.getElementById('editCanvasWrapper');
- const container = document.getElementById('editCanvasContainer');
-
- if (!canvas || !wrapper || !container) return null;
-
- // Get the actual canvas element bounds (after CSS transform)
- const canvasRect = canvas.getBoundingClientRect();
-
- // Calculate relative position within the actual canvas bounds
- const relativeX = (clientX - canvasRect.left) / canvasRect.width * canvas.width;
- const relativeY = (clientY - canvasRect.top) / canvasRect.height * canvas.height;
-
- // Convert to canvas coordinates
- const canvasX = Math.floor(relativeX);
- const canvasY = Math.floor(relativeY);
-
- // Bounds checking
- if (canvasX < 0 || canvasX >= canvas.width || canvasY < 0 || canvasY >= canvas.height) {
- return null;
- }
-
- return { x: canvasX, y: canvasY };
- }
-
- function setupCanvasDrawing() {
- const editCanvas = document.getElementById('editCanvas');
- const ctx = editCanvas.getContext('2d');
-
- let isDrawing = false;
- let isPanning = false;
- let lastX = 0;
- let lastY = 0;
- let panStartX = 0;
- let panStartY = 0;
-
- const getMousePos = (e) => {
- return mapClientToCanvas(e.clientX, e.clientY);
- };
-
- editCanvas.onmousedown = (e) => {
- if (e.button === 2 || e.ctrlKey) { // Right click or Ctrl+click for panning
- editState.isPanning = true;
- panStartX = e.clientX - editState.panX;
- panStartY = e.clientY - editState.panY;
- editCanvas.style.cursor = 'move';
- e.preventDefault();
- return;
- }
-
- const pos = getMousePos(e);
- if (!pos) return;
-
- if (editState.currentTool === 'eyedropper') {
- handleEyedropper(pos.x, pos.y);
- return;
- }
-
- if (editState.currentTool === 'fill') {
- floodFill(pos.x, pos.y, editState.currentColor);
- saveEditState();
- return;
- }
-
- editState.isDrawing = true;
- editState.lastPaintPos = { x: pos.x, y: pos.y };
- lastX = pos.x;
- lastY = pos.y;
-
- paintAtPosition(pos.x, pos.y, true);
- };
-
- editCanvas.onmousemove = (e) => {
- const pos = getMousePos(e);
-
- if (pos) {
- editState.mouseX = pos.x;
- editState.mouseY = pos.y;
- updateStatusBar(pos.x, pos.y);
- }
-
- if (editState.isPanning) {
- editState.panX = e.clientX - panStartX;
- editState.panY = e.clientY - panStartY;
- constrainPan();
- updateCanvasTransform();
- updateMinimap();
- return;
- }
-
- if (!editState.isDrawing) {
- return;
- }
-
- if (pos && editState.lastPaintPos) {
- paintAtPosition(pos.x, pos.y, true);
- editState.lastPaintPos = { x: pos.x, y: pos.y };
- }
- };
-
- editCanvas.onmouseup = (e) => {
- if (editState.isPanning) {
- editState.isPanning = false;
- selectTool(editState.currentTool); // Restore cursor
- return;
- }
-
- if (editState.isDrawing) {
- editState.isDrawing = false;
- editState.lastPaintPos = null;
- saveEditState();
- }
- };
-
- editCanvas.onmouseleave = () => {
- if (editState.isPanning) {
- editState.isPanning = false;
- selectTool(editState.currentTool); // Restore cursor
- }
-
- if (editState.isDrawing) {
- editState.isDrawing = false;
- editState.lastPaintPos = null;
- saveEditState();
- }
- };
-
- // Prevent context menu on right click
- editCanvas.oncontextmenu = (e) => {
- e.preventDefault();
- return false;
- };
-
- // Enhanced zoom with mouse wheel (zoom to cursor)
- editCanvas.onwheel = (e) => {
- e.preventDefault();
- const zoomFactor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
- zoomToPoint(editState.zoom * zoomFactor, e.clientX, e.clientY);
- };
- }
-
- function paintAtPosition(x, y, isMouseDown = true) {
- if (!isMouseDown || !editState.currentColor) return;
-
- const canvas = document.getElementById('editCanvas');
- const ctx = canvas.getContext('2d');
-
- if (editState.lastPaintPos && editState.currentTool === 'paint') {
- // Draw line from last position to current position
- drawLine(ctx, editState.lastPaintPos.x, editState.lastPaintPos.y, x, y, editState.currentTool);
- } else {
- // Single brush stroke
- drawBrush(ctx, x, y, editState.currentTool);
- }
-
- editState.lastPaintPos = { x, y };
-
- // Update minimap thumbnail
- if (!editState.updatePending) {
- editState.updatePending = true;
- requestAnimationFrame(() => {
- setupMinimap();
- editState.updatePending = false;
- });
- }
- }
-
- function drawBrush(ctx, x, y, tool) {
- const size = editState.brushSize;
- const halfSize = Math.floor(size / 2);
-
- if (tool === 'paint') {
- ctx.fillStyle = editState.currentColor;
- for (let dx = 0; dx < size; dx++) {
- for (let dy = 0; dy < size; dy++) {
- // Center the brush at the cursor position
- const px = x - halfSize + dx;
- const py = y - halfSize + dy;
-
- if (px >= 0 && px < ctx.canvas.width && py >= 0 && py < ctx.canvas.height) {
- ctx.fillRect(px, py, 1, 1);
- }
- }
- }
- } else if (tool === 'erase') {
- for (let dx = 0; dx < size; dx++) {
- for (let dy = 0; dy < size; dy++) {
- // Center the brush at the cursor position
- const px = x - halfSize + dx;
- const py = y - halfSize + dy;
-
- if (px >= 0 && px < ctx.canvas.width && py >= 0 && py < ctx.canvas.height) {
- ctx.clearRect(px, py, 1, 1);
- }
- }
- }
- }
- }
-
- function drawPixel(x, y) {
- drawBrush(document.getElementById('editCanvas').getContext('2d'), x, y, 'paint');
- }
-
- function erasePixel(x, y) {
- const editCanvas = document.getElementById('editCanvas');
- const ctx = editCanvas.getContext('2d');
-
- const size = editState.brushSize;
- const halfSize = Math.floor(size / 2);
-
- for (let dx = 0; dx < size; dx++) {
- for (let dy = 0; dy < size; dy++) {
- // Center the brush at the cursor position (consistent with drawBrush)
- const px = x - halfSize + dx;
- const py = y - halfSize + dy;
-
- if (px >= 0 && px < editCanvas.width && py >= 0 && py < editCanvas.height) {
- ctx.clearRect(px, py, 1, 1);
- }
- }
- }
- }
-
- function drawLine(ctx, x1, y1, x2, y2, tool) {
- // Bresenham's line algorithm with tool support
- const dx = Math.abs(x2 - x1);
- const dy = Math.abs(y2 - y1);
- const sx = x1 < x2 ? 1 : -1;
- const sy = y1 < y2 ? 1 : -1;
- let err = dx - dy;
-
- let x = x1;
- let y = y1;
-
- while (true) {
- drawBrush(ctx, x, y, tool);
-
- if (x === x2 && y === y2) break;
-
- const e2 = 2 * err;
- if (e2 > -dy) {
- err -= dy;
- x += sx;
- }
- if (e2 < dx) {
- err += dx;
- y += sy;
- }
- }
- }
-
- function eraseLine(x1, y1, x2, y2) {
- // Same as drawLine but with erase
- const dx = Math.abs(x2 - x1);
- const dy = Math.abs(y2 - y1);
- const sx = x1 < x2 ? 1 : -1;
- const sy = y1 < y2 ? 1 : -1;
- let err = dx - dy;
-
- let x = x1;
- let y = y1;
-
- while (true) {
- erasePixel(x, y);
-
- if (x === x2 && y === y2) break;
-
- const e2 = 2 * err;
- if (e2 > -dy) {
- err -= dy;
- x += sx;
- }
- if (e2 < dx) {
- err += dx;
- y += sy;
- }
- }
- }
-
- function selectTool(tool) {
- editState.currentTool = tool;
-
- document.querySelectorAll('.edit-tool').forEach(btn => {
- btn.classList.remove('active');
- });
-
- const editCanvas = document.getElementById('editCanvas');
-
- switch (tool) {
- case 'paint':
- document.getElementById('paintBrush').classList.add('active');
- editCanvas.style.cursor = 'crosshair';
- break;
- case 'erase':
- document.getElementById('eraseTool').classList.add('active');
- editCanvas.style.cursor = 'not-allowed';
- break;
- case 'eyedropper':
- document.getElementById('eyedropperTool').classList.add('active');
- editCanvas.style.cursor = 'copy';
- break;
- case 'fill':
- document.getElementById('fillTool').classList.add('active');
- editCanvas.style.cursor = 'pointer';
- break;
- }
- }
-
- function updateBrushSize(size) {
- editState.brushSize = size;
- }
-
- function initializeEditColorPalette() {
- const colorGrid = document.getElementById('editColorGrid');
- const currentColorDisplay = document.getElementById('currentColorDisplay');
-
- colorGrid.innerHTML = '';
-
- let availableColors = [];
-
- // Try to get colors from state first
- if (state && state.availableColors && state.availableColors.length > 0) {
- availableColors = state.availableColors.map(color => ({
- id: color.id,
- name: color.name,
- rgb: color.rgb,
- hex: `#${color.rgb[0].toString(16).padStart(2, '0')}${color.rgb[1].toString(16).padStart(2, '0')}${color.rgb[2].toString(16).padStart(2, '0')}`
- }));
- } else {
- // Fallback to CONFIG.COLOR_MAP
- availableColors = Object.values(CONFIG.COLOR_MAP)
- .filter(color => color.rgb !== null)
- .map(color => ({
- id: color.id,
- name: color.name,
- rgb: [color.rgb.r, color.rgb.g, color.rgb.b],
- hex: `#${color.rgb.r.toString(16).padStart(2, '0')}${color.rgb.g.toString(16).padStart(2, '0')}${color.rgb.b.toString(16).padStart(2, '0')}`
- }));
- }
-
- // Fallback to basic colors if nothing available
- if (availableColors.length === 0) {
- availableColors = [
- { id: 0, name: 'Black', rgb: [0, 0, 0], hex: '#000000' },
- { id: 1, name: 'White', rgb: [255, 255, 255], hex: '#ffffff' },
- { id: 2, name: 'Red', rgb: [255, 0, 0], hex: '#ff0000' },
- { id: 3, name: 'Green', rgb: [0, 255, 0], hex: '#00ff00' },
- { id: 4, name: 'Blue', rgb: [0, 0, 255], hex: '#0000ff' }
- ];
- }
-
- // Update color count
- const colorCount = document.getElementById('editColorCount');
- if (colorCount) {
- colorCount.textContent = availableColors.length;
- }
-
- availableColors.forEach(color => {
- const colorBtn = document.createElement('button');
- colorBtn.className = 'color-btn';
- colorBtn.style.backgroundColor = color.hex;
- colorBtn.title = `${color.name} (${color.hex})`;
- colorBtn.dataset.colorId = color.id;
- colorBtn.dataset.colorHex = color.hex;
-
- colorBtn.onclick = () => {
- editState.currentColor = color.hex;
- editState.currentColorId = color.id;
- currentColorDisplay.style.backgroundColor = color.hex;
-
- // Update active color
- document.querySelectorAll('.color-btn').forEach(btn => {
- btn.classList.remove('selected');
- });
- colorBtn.classList.add('selected');
-
- updateStatusBar(editState.mouseX, editState.mouseY);
- };
-
- colorGrid.appendChild(colorBtn);
- });
-
- // Set first color as default
- if (availableColors.length > 0) {
- editState.currentColor = availableColors[0].hex;
- editState.currentColorId = availableColors[0].id;
- currentColorDisplay.style.backgroundColor = availableColors[0].hex;
- colorGrid.firstChild.classList.add('selected');
- }
- }
-
- function saveEditState() {
- const editCanvas = document.getElementById('editCanvas');
- const imageData = editCanvas.toDataURL();
-
- editState.undoStack.push(imageData);
-
- // Limit undo stack size
- if (editState.undoStack.length > 50) {
- editState.undoStack.shift();
- }
-
- // Clear redo stack when new action is performed
- editState.redoStack = [];
-
- updateUndoRedoButtons();
- }
-
- function undoEdit() {
- if (editState.undoStack.length <= 1) return;
-
- const currentState = editState.undoStack.pop();
- editState.redoStack.push(currentState);
-
- const previousState = editState.undoStack[editState.undoStack.length - 1];
- loadEditState(previousState);
-
- updateUndoRedoButtons();
- }
-
- function redoEdit() {
- if (editState.redoStack.length === 0) return;
-
- const nextState = editState.redoStack.pop();
- editState.undoStack.push(nextState);
-
- loadEditState(nextState);
-
- updateUndoRedoButtons();
- }
-
- function loadEditState(imageData) {
- const editCanvas = document.getElementById('editCanvas');
- const ctx = editCanvas.getContext('2d');
-
- const img = new Image();
- img.onload = () => {
- ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
- // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
- ctx.drawImage(img, 0, 0);
- };
- img.src = imageData;
- }
-
- function updateUndoRedoButtons() {
- const undoBtn = document.getElementById('undoBtn');
- const redoBtn = document.getElementById('redoBtn');
-
- if (undoBtn) {
- undoBtn.disabled = editState.undoStack.length <= 1;
- }
-
- if (redoBtn) {
- redoBtn.disabled = editState.redoStack.length === 0;
- }
- }
-
- function updateCanvasTransform() {
- const wrapper = document.getElementById('editCanvasWrapper');
- if (wrapper) {
- wrapper.style.transform = `translate(${editState.panX}px, ${editState.panY}px) scale(${editState.zoom})`;
- }
-
- // Update zoom select
- const zoomSelect = document.getElementById('zoomSelect');
- if (zoomSelect) {
- // If exact value exists, use it, otherwise show nearest but update displayed text
- const exact = ZOOM_LEVELS.find(z => z === editState.zoom);
- zoomSelect.value = exact != null ? exact : ZOOM_LEVELS.reduce((prev, curr) => Math.abs(curr - editState.zoom) < Math.abs(prev - editState.zoom) ? curr : prev);
- // Also update status bar zoom text
- updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
- }
- }
-
- function setupCanvasContainer() {
- const canvasContainer = document.getElementById('editCanvasContainer');
- const editCanvas = document.getElementById('editCanvas');
- const wrapper = document.getElementById('editCanvasWrapper');
-
- if (!canvasContainer || !wrapper) return;
-
- // Setup container styles
- canvasContainer.style.cssText = `
- position: relative;
- overflow: hidden;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- background:
- linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
- linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
- linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
- linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
- background-size: 20px 20px;
- background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
- background-color: #ddd;
- `;
-
- // Setup wrapper styles
- wrapper.style.cssText = `
- position: relative;
- transform-origin: center center;
- display: inline-block;
- box-shadow: 0 4px 20px rgba(0,0,0,0.3);
- border: 2px solid #333;
- background: white;
- `;
- }
-
- function zoomToPoint(newZoom, clientX, clientY) {
- const container = document.getElementById('editCanvasContainer');
- const wrapper = document.getElementById('editCanvasWrapper');
-
- if (!container || !wrapper) return;
-
- newZoom = Math.max(0.1, Math.min(32, newZoom));
-
- if (clientX !== undefined && clientY !== undefined) {
- // Get current transform values
- const oldZoom = editState.zoom;
- const rect = container.getBoundingClientRect();
-
- // Calculate zoom center point relative to container
- const containerCenterX = rect.left + rect.width / 2;
- const containerCenterY = rect.top + rect.height / 2;
-
- // Calculate offset to keep zoom point centered
- const offsetX = (clientX - containerCenterX) * (1 - newZoom / oldZoom);
- const offsetY = (clientY - containerCenterY) * (1 - newZoom / oldZoom);
-
- editState.panX += offsetX;
- editState.panY += offsetY;
- }
-
- editState.zoom = newZoom;
- constrainPan();
- updateCanvasTransform();
- updateMinimap();
- updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
- }
-
- function constrainPan() {
- const container = document.getElementById('editCanvasContainer');
- const canvas = document.getElementById('editCanvas');
-
- if (!container || !canvas) return;
-
- const containerRect = container.getBoundingClientRect();
- const scaledWidth = canvas.width * editState.zoom;
- const scaledHeight = canvas.height * editState.zoom;
-
- // Small padding around edges to avoid snapping against borders
- const padding = 10;
-
- // Calculate limits to keep canvas somewhat visible
- const maxPanX = Math.max(0, (scaledWidth - containerRect.width) / 2 + padding);
- const maxPanY = Math.max(0, (scaledHeight - containerRect.height) / 2 + padding);
-
- editState.panX = Math.max(-maxPanX, Math.min(maxPanX, editState.panX));
- editState.panY = Math.max(-maxPanY, Math.min(maxPanY, editState.panY));
- }
-
- function setZoom(zoom) {
- zoomToPoint(zoom);
- }
-
- function zoomIn() {
- const currentIndex = ZOOM_LEVELS.findIndex(z => z >= editState.zoom);
- const nextIndex = Math.min(currentIndex + 1, ZOOM_LEVELS.length - 1);
- setZoom(ZOOM_LEVELS[nextIndex]);
- }
-
- function zoomOut() {
- const currentIndex = ZOOM_LEVELS.findIndex(z => z >= editState.zoom);
- const prevIndex = Math.max(currentIndex - 1, 0);
- setZoom(ZOOM_LEVELS[prevIndex]);
- }
-
- function fitToWindow() {
- const container = document.getElementById('editCanvasContainer');
- const canvas = document.getElementById('editCanvas');
-
- if (!container || !canvas) return;
-
- const containerRect = container.getBoundingClientRect();
- const padding = 40;
-
- const scaleX = (containerRect.width - padding) / canvas.width;
- const scaleY = (containerRect.height - padding) / canvas.height;
- const fitZoom = Math.max(0.1, Math.min(scaleX, scaleY));
-
- // Center the canvas
- editState.zoom = fitZoom;
- editState.panX = 0;
- editState.panY = 0;
- updateCanvasTransform();
- updateMinimap();
- updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
- }
-
- function centerCanvas() {
- editState.panX = 0;
- editState.panY = 0;
- updateCanvasTransform();
- updateMinimap();
- }
-
- function resetEditView() {
- editState.zoom = 1;
- editState.panX = 0;
- editState.panY = 0;
-
- updateCanvasTransform();
- updateMinimap();
- }
-
- function setupMinimap() {
- const minimapCanvas = document.getElementById('minimapCanvas');
- const editCanvas = document.getElementById('editCanvas');
- const minimapContainer = document.getElementById('minimapContainer');
-
- if (!minimapCanvas || !editCanvas) return;
-
- // Show minimap only for larger images
- if (editCanvas.width < 100 || editCanvas.height < 100) {
- if (minimapContainer) minimapContainer.style.display = 'none';
- return;
- }
-
- // Calculate minimap size
- const maxSize = 150;
- const scale = Math.min(maxSize / editCanvas.width, maxSize / editCanvas.height);
-
- minimapCanvas.width = editCanvas.width * scale;
- minimapCanvas.height = editCanvas.height * scale;
-
- // Draw thumbnail
- const minimapCtx = minimapCanvas.getContext('2d');
- minimapCtx.imageSmoothingEnabled = false;
- // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
- minimapCtx.drawImage(editCanvas, 0, 0, minimapCanvas.width, minimapCanvas.height);
-
- // Update viewport indicator
- updateMinimap();
- }
-
- function updateMinimap() {
- const viewport = document.getElementById('minimapViewport');
- const minimapCanvas = document.getElementById('minimapCanvas');
- const container = document.getElementById('editCanvasContainer');
- const editCanvas = document.getElementById('editCanvas');
-
- if (!viewport || !minimapCanvas || !container || !editCanvas) return;
-
- const containerRect = container.getBoundingClientRect();
- const scale = minimapCanvas.width / editCanvas.width;
-
- // Calculate visible area in minimap coordinates
- const visibleWidth = Math.min(containerRect.width / editState.zoom * scale, minimapCanvas.width);
- const visibleHeight = Math.min(containerRect.height / editState.zoom * scale, minimapCanvas.height);
-
- const viewportX = (minimapCanvas.width / 2) - (editState.panX / editState.zoom * scale) - (visibleWidth / 2);
- const viewportY = (minimapCanvas.height / 2) - (editState.panY / editState.zoom * scale) - (visibleHeight / 2);
-
- viewport.style.width = `${visibleWidth}px`;
- viewport.style.height = `${visibleHeight}px`;
- viewport.style.left = `${Math.max(0, Math.min(minimapCanvas.width - visibleWidth, viewportX))}px`;
- viewport.style.top = `${Math.max(0, Math.min(minimapCanvas.height - visibleHeight, viewportY))}px`;
- }
-
- function navigateToMinimapPosition(e) {
- const minimapCanvas = document.getElementById('minimapCanvas');
- const editCanvas = document.getElementById('editCanvas');
-
- if (!minimapCanvas || !editCanvas) return;
-
- const rect = minimapCanvas.getBoundingClientRect();
- const x = (e.clientX - rect.left) / minimapCanvas.width;
- const y = (e.clientY - rect.top) / minimapCanvas.height;
-
- // Convert to canvas coordinates and center
- const targetX = (x - 0.5) * editCanvas.width * editState.zoom;
- const targetY = (y - 0.5) * editCanvas.height * editState.zoom;
-
- editState.panX = -targetX;
- editState.panY = -targetY;
-
- constrainPan();
- updateCanvasTransform();
- updateMinimap();
- }
-
- function updateStatusBar(x, y) {
- const statusBar = document.getElementById('editStatusBar');
- if (statusBar) {
- const zoomPercent = Math.round(editState.zoom * 100);
- statusBar.textContent = `Position: (${x}, ${y}) | Color: ${editState.currentColor || '#000000'} | Zoom: ${zoomPercent}%`;
- }
- }
-
- function handleEyedropper(x, y) {
- const editCanvas = document.getElementById('editCanvas');
- const ctx = editCanvas.getContext('2d');
-
- if (x >= 0 && x < editCanvas.width && y >= 0 && y < editCanvas.height) {
- const imageData = ctx.getImageData(x, y, 1, 1);
- const [r, g, b] = imageData.data;
- const pickedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
-
- editState.currentColor = pickedColor;
- const currentColorDisplay = document.getElementById('currentColorDisplay');
- if (currentColorDisplay) {
- currentColorDisplay.style.backgroundColor = pickedColor;
- }
-
- // Try to find matching color in palette
- document.querySelectorAll('.edit-color-btn').forEach(btn => {
- btn.classList.remove('active');
- if (btn.dataset.colorHex === pickedColor) {
- btn.classList.add('active');
- editState.currentColorId = parseInt(btn.dataset.colorId);
- }
- });
-
- updateStatusBar(x, y);
- }
- }
-
- function floodFill(startX, startY, fillColor) {
- const editCanvas = document.getElementById('editCanvas');
- const ctx = editCanvas.getContext('2d');
- const imageData = ctx.getImageData(0, 0, editCanvas.width, editCanvas.height);
- const data = imageData.data;
-
- if (startX < 0 || startX >= editCanvas.width || startY < 0 || startY >= editCanvas.height) return;
-
- const startIndex = (startY * editCanvas.width + startX) * 4;
- const startR = data[startIndex];
- const startG = data[startIndex + 1];
- const startB = data[startIndex + 2];
- const startA = data[startIndex + 3];
-
- // Convert fill color to RGB
- const fillR = parseInt(fillColor.slice(1, 3), 16);
- const fillG = parseInt(fillColor.slice(3, 5), 16);
- const fillB = parseInt(fillColor.slice(5, 7), 16);
-
- // Don't fill if the color is already the same
- if (startR === fillR && startG === fillG && startB === fillB) return;
-
- const pixelsToCheck = [{ x: startX, y: startY }];
- const checkedPixels = new Set();
-
- while (pixelsToCheck.length > 0) {
- const { x, y } = pixelsToCheck.pop();
- const key = `${x},${y}`;
-
- if (checkedPixels.has(key)) continue;
- checkedPixels.add(key);
-
- if (x < 0 || x >= editCanvas.width || y < 0 || y >= editCanvas.height) continue;
-
- const index = (y * editCanvas.width + x) * 4;
- const r = data[index];
- const g = data[index + 1];
- const b = data[index + 2];
- const a = data[index + 3];
-
- if (r === startR && g === startG && b === startB && a === startA) {
- data[index] = fillR;
- data[index + 1] = fillG;
- data[index + 2] = fillB;
- data[index + 3] = 255;
-
- pixelsToCheck.push({ x: x + 1, y });
- pixelsToCheck.push({ x: x - 1, y });
- pixelsToCheck.push({ x, y: y + 1 });
- pixelsToCheck.push({ x, y: y - 1 });
- }
- }
-
- ctx.putImageData(imageData, 0, 0);
- }
-
- // Brush preview removed per user request
- function hideBrushPreview() {
- // No-op - brush preview disabled
- }
-
- function drawGrid(ctx, width, height) {
- if (!editState.showGrid || editState.zoom < 4) return;
-
- ctx.save();
- ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)';
- ctx.lineWidth = 1 / editState.zoom;
-
- for (let x = 0; x <= width; x++) {
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, height);
- ctx.stroke();
- }
-
- for (let y = 0; y <= height; y++) {
- ctx.beginPath();
- ctx.moveTo(0, y);
- ctx.lineTo(width, y);
- ctx.stroke();
- }
-
- ctx.restore();
- }
-
- function drawCheckerboardBackground(ctx, width, height) {
- const checkerSize = 8; // Size of each checker square
- ctx.fillStyle = '#f0f0f0'; // Light gray
- ctx.fillRect(0, 0, width, height);
-
- ctx.fillStyle = '#e0e0e0'; // Slightly darker gray
- for (let x = 0; x < width; x += checkerSize) {
- for (let y = 0; y < height; y += checkerSize) {
- if ((Math.floor(x / checkerSize) + Math.floor(y / checkerSize)) % 2 === 1) {
- ctx.fillRect(x, y, checkerSize, checkerSize);
- }
- }
- }
- }
-
- function redrawCanvas() {
- if (editState.undoStack.length === 0) return;
-
- const currentState = editState.undoStack[editState.undoStack.length - 1];
- const editCanvas = document.getElementById('editCanvas');
- const ctx = editCanvas.getContext('2d');
-
- const img = new Image();
- img.onload = () => {
- ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
- // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
- ctx.drawImage(img, 0, 0);
- drawGrid(ctx, editCanvas.width, editCanvas.height);
- };
- img.src = currentState;
- }
-
- function setupTouchSupport() {
- const canvas = document.getElementById('editCanvas');
- if (!canvas) return;
-
- let touchStartTime = 0;
-
- canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
- canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
- canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
-
- function handleTouchStart(e) {
- e.preventDefault();
- touchStartTime = Date.now();
-
- if (e.touches.length === 1) {
- // Single touch - start painting
- const touch = e.touches[0];
- const coords = mapClientToCanvas(touch.clientX, touch.clientY);
- if (coords) {
- editState.isDrawing = true;
- editState.lastPaintPos = { x: coords.x, y: coords.y };
- paintAtPosition(coords.x, coords.y, true);
- }
- } else if (e.touches.length === 2) {
- // Two finger - prepare for zoom/pan
- const touch1 = e.touches[0];
- const touch2 = e.touches[1];
-
- editState.lastTouchDistance = Math.hypot(
- touch2.clientX - touch1.clientX,
- touch2.clientY - touch1.clientY
- );
-
- editState.lastTouchCenter = {
- x: (touch1.clientX + touch2.clientX) / 2,
- y: (touch1.clientY + touch2.clientY) / 2
- };
- }
- }
-
- function handleTouchMove(e) {
- e.preventDefault();
-
- if (e.touches.length === 1 && editState.isDrawing) {
- // Continue painting
- const touch = e.touches[0];
- const coords = mapClientToCanvas(touch.clientX, touch.clientY);
- if (coords) {
- paintAtPosition(coords.x, coords.y, true);
- }
- } else if (e.touches.length === 2) {
- // Pinch zoom and pan
- const touch1 = e.touches[0];
- const touch2 = e.touches[1];
-
- const currentDistance = Math.hypot(
- touch2.clientX - touch1.clientX,
- touch2.clientY - touch1.clientY
- );
-
- const currentCenter = {
- x: (touch1.clientX + touch2.clientX) / 2,
- y: (touch1.clientY + touch2.clientY) / 2
- };
-
- // Zoom based on distance change
- if (editState.lastTouchDistance > 0) {
- const zoomFactor = currentDistance / editState.lastTouchDistance;
- const newZoom = Math.max(0.1, Math.min(32, editState.zoom * zoomFactor));
- zoomToPoint(newZoom, currentCenter.x, currentCenter.y);
- }
-
- // Pan based on center movement
- editState.panX += currentCenter.x - editState.lastTouchCenter.x;
- editState.panY += currentCenter.y - editState.lastTouchCenter.y;
-
- editState.lastTouchDistance = currentDistance;
- editState.lastTouchCenter = currentCenter;
-
- constrainPan();
- updateCanvasTransform();
- updateMinimap();
- }
- }
-
- function handleTouchEnd(e) {
- e.preventDefault();
-
- if (editState.isDrawing) {
- editState.isDrawing = false;
- editState.lastPaintPos = null;
- saveEditState();
- }
-
- if (e.touches.length === 0) {
- editState.lastTouchDistance = 0;
- }
- }
- }
-
- function setupKeyboardShortcuts() {
- document.addEventListener('keydown', (e) => {
- // Only handle shortcuts when edit panel is visible
- const editOverlay = document.getElementById('editOverlay');
- if (!editOverlay || editOverlay.style.display === 'none') return;
-
- // Prevent default for handled keys
- const handledKeys = ['b', 'e', 'i', 'f', 'g', 'z', '[', ']'];
- if (handledKeys.includes(e.key.toLowerCase()) || (e.ctrlKey && e.key.toLowerCase() === 'z')) {
- e.preventDefault();
- }
-
- switch (e.key.toLowerCase()) {
- case 'b': // Brush
- selectTool('paint');
- break;
- case 'e': // Erase
- selectTool('erase');
- break;
- case 'i': // Eyedropper
- selectTool('eyedropper');
- break;
- case 'f': // Fill
- selectTool('fill');
- break;
- case 'g': // Grid
- const showGrid = document.getElementById('showGrid');
- if (showGrid) showGrid.click();
- break;
- case 'z':
- if (e.ctrlKey || e.metaKey) {
- if (e.shiftKey) {
- redoEdit();
- } else {
- undoEdit();
- }
- }
- break;
- case '=':
- case '+':
- if (e.ctrlKey || e.metaKey) {
- zoomIn();
- }
- break;
- case '-':
- if (e.ctrlKey || e.metaKey) {
- zoomOut();
- }
- break;
- case '0':
- if (e.ctrlKey || e.metaKey) {
- fitToWindow();
- }
- break;
- case '1':
- if (e.ctrlKey || e.metaKey) {
- setZoom(1);
- }
- break;
- case '[': // Decrease brush size
- const brushSize = document.getElementById('brushSize');
- const currentSize = parseInt(brushSize.value);
- if (currentSize > 1) {
- brushSize.value = currentSize - 1;
- document.getElementById('brushSizeValue').textContent = currentSize - 1;
- updateBrushSize(currentSize - 1);
- }
- break;
- case ']': // Increase brush size
- const brushSize2 = document.getElementById('brushSize');
- const currentSize2 = parseInt(brushSize2.value);
- if (currentSize2 < 20) {
- brushSize2.value = currentSize2 + 1;
- document.getElementById('brushSizeValue').textContent = currentSize2 + 1;
- updateBrushSize(currentSize2 + 1);
- }
- break;
- }
- });
- }
-
- function applyEditChanges() {
- try {
- const editCanvas = document.getElementById('editCanvas');
- if (!editCanvas) {
- throw new Error('Edit canvas not found');
- }
-
- // Find the resize canvas in the resize panel
- const resizeCanvas = document.getElementById('resizeCanvas');
- if (!resizeCanvas) {
- throw new Error('Resize canvas not found');
- }
-
- const baseCtx = resizeCanvas.getContext('2d');
- if (!baseCtx) {
- throw new Error('Resize canvas context not available');
- }
-
- // Make sure the resize canvas has the same dimensions as the edit canvas
- if (resizeCanvas.width !== editCanvas.width || resizeCanvas.height !== editCanvas.height) {
- resizeCanvas.width = editCanvas.width;
- resizeCanvas.height = editCanvas.height;
- }
-
- // Clear resize canvas
- baseCtx.clearRect(0, 0, resizeCanvas.width, resizeCanvas.height);
-
- // Copy edited image to resize canvas (no checkerboard since we removed it entirely)
- baseCtx.imageSmoothingEnabled = false;
- baseCtx.drawImage(editCanvas, 0, 0);
-
- // CRITICAL: Completely replace the template system with edited artwork
- const editedImageData = editCanvas.toDataURL();
-
- // Create new processor with edited image as the template
- if (window.WPlaceImageProcessor) {
- const newProcessor = new window.WPlaceImageProcessor(editedImageData);
- newProcessor.load().then(() => {
- // Get the processed pixel data from the edited canvas
- const editCtx = editCanvas.getContext('2d');
- const editImageData = editCtx.getImageData(0, 0, editCanvas.width, editCanvas.height);
- const pixels = editImageData.data;
-
- // Count valid pixels in the edited image
- let totalValidPixels = 0;
- for (let i = 0; i < pixels.length; i += 4) {
- const a = pixels[i + 3];
- const r = pixels[i];
- const g = pixels[i + 1];
- const b = pixels[i + 2];
-
- const isTransparent = !state.paintTransparentPixels && a < state.customTransparencyThreshold;
- const isWhiteAndSkipped = !state.paintWhitePixels && Utils.isWhitePixel(r, g, b);
-
- if (!isTransparent && !isWhiteAndSkipped) {
- totalValidPixels++;
- }
- }
-
- // COMPLETELY REBUILD state.imageData with the edited artwork
- state.imageData = {
- width: editCanvas.width,
- height: editCanvas.height,
- pixels: pixels,
- totalPixels: totalValidPixels,
- processor: newProcessor,
- };
-
- // CRITICAL: Update state.originalImage so resize panel uses edited artwork as base template
- state.originalImage = {
- dataUrl: editedImageData,
- width: editCanvas.width,
- height: editCanvas.height
- };
-
- // Update state with new totals
- state.totalPixels = totalValidPixels;
- state.paintedPixels = 0; // Reset progress since this is a new template
- state.currentPaintingColor = null; // Reset color tracking
- state.imageLoaded = true;
-
- // Update local processors
- if (typeof processor !== 'undefined') {
- processor = newProcessor;
- console.log('š Updated local processor with edited artwork');
- }
- if (typeof baseProcessor !== 'undefined') {
- baseProcessor = newProcessor;
- console.log('š Updated baseProcessor with edited artwork');
- }
-
- // Force regeneration of overlays by clearing cached mask data
- if (typeof window._maskImageData !== 'undefined') {
- delete window._maskImageData;
- }
-
- // Update UI to reflect the new template
- if (typeof updateUI === 'function') {
- updateUI();
- }
-
- // Show loading and properly reload resize panel with new template
- Utils.showAlert('Updating template... Please wait.', 'info');
-
- // Give more time for template to fully update and force resize panel reload
- setTimeout(() => {
- // Hide edit overlay first
- document.getElementById('editOverlay').style.display = 'none';
-
- // Completely reload resize dialog with updated processor
- setTimeout(() => {
- // Clean up existing dialog
- if (typeof _resizeDialogCleanup === 'function') {
- _resizeDialogCleanup();
- }
-
- // Force complete reload of resize dialog with new processor
- setTimeout(() => {
- showResizeDialog(newProcessor);
-
- console.log('ā
Template COMPLETELY replaced with edited artwork');
- console.log(`š New template stats: ${editCanvas.width}x${editCanvas.height}, ${totalValidPixels} pixels`);
- Utils.showAlert('Template successfully replaced with your edited artwork!', 'success');
- }, 100);
- }, 300);
- }, 100);
- }).catch((error) => {
- console.error('Failed to load new processor:', error);
- Utils.showAlert('Failed to update template. Please try again.', 'error');
- });
- } else {
- console.error('WPlaceImageProcessor not available');
- Utils.showAlert('Image processor not available. Please reload the page.', 'error');
- }
-
- console.log('ā
Edit changes applied - template replacement in progress');
- } catch (error) {
- console.error('Error applying edit changes:', error);
- Utils.showAlert('Failed to apply changes. Please try again.', 'error');
- }
- }
-
- if (uploadBtn) {
- uploadBtn.addEventListener('click', async () => {
- const availableColors = Utils.extractAvailableColors();
- if (availableColors === null || availableColors.length < 10) {
- updateUI('noColorsFound', 'error');
- Utils.showAlert(Utils.t('noColorsFound'), 'error');
- return;
- }
-
- if (!state.colorsChecked) {
- state.availableColors = availableColors;
- state.colorsChecked = true;
- updateUI('colorsFound', 'success', { count: availableColors.length });
- await updateStats();
- selectPosBtn.disabled = false;
- // Only enable resize button if image is also loaded
- if (state.imageLoaded) {
- resizeBtn.disabled = false;
- }
- }
-
- try {
- updateUI('loadingImage', 'default');
- const imageSrc = await Utils.createImageUploader();
- if (!imageSrc) {
- updateUI('colorsFound', 'success', {
- count: state.availableColors.length,
- });
- return;
- }
-
- const processor = new ImageProcessor(imageSrc);
- await processor.load();
-
- const { width, height } = processor.getDimensions();
- const pixels = processor.getPixelData();
-
- let totalValidPixels = 0;
- for (let i = 0; i < pixels.length; i += 4) {
- const isTransparent =
- !state.paintTransparentPixels &&
- pixels[i + 3] < (state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD);
- const isWhiteAndSkipped =
- !state.paintWhitePixels &&
- Utils.isWhitePixel(pixels[i], pixels[i + 1], pixels[i + 2]);
- if (!isTransparent && !isWhiteAndSkipped) {
- totalValidPixels++;
- }
- }
-
- state.imageData = {
- width,
- height,
- pixels,
- totalPixels: totalValidPixels,
- processor,
- };
-
- state.totalPixels = totalValidPixels;
- state.paintedPixels = 0;
- state.currentPaintingColor = null; // Reset color tracking
- state.imageLoaded = true;
-
- // Reset session-specific flags when a new image is loaded
- state.preFilteringDone = false;
- state.progressResetDone = false;
- console.log('š Reset session flags for new image load');
-
- // Keep existing lastPosition to continue from where we left off
- // state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
-
- // Initialize painted map for tracking
- Utils.initializePaintedMap(width, height);
-
- // New image: clear previous resize settings
- state.resizeSettings = null;
- // Also clear any previous ignore mask
- state.resizeIgnoreMask = null;
- // Save original image for this browser (dataUrl + dims)
- state.originalImage = { dataUrl: imageSrc, width, height };
- saveBotSettings();
-
- // Use the original image for the overlay initially
- const imageBitmap = await createImageBitmap(processor.img);
- await overlayManager.setImage(imageBitmap);
- overlayManager.enable();
- toggleOverlayBtn.disabled = false;
- toggleOverlayBtn.classList.add('active');
- toggleOverlayBtn.setAttribute('aria-pressed', 'true');
-
- // Only enable resize button if colors have also been captured
- if (state.colorsChecked) {
- resizeBtn.disabled = false;
- }
- saveBtn.disabled = false;
-
- if (state.startPosition) {
- startBtn.disabled = false;
- }
-
- await updateStats();
- updateDataButtons();
- updateUI('imageLoaded', 'success', { count: totalValidPixels });
- } catch {
- updateUI('imageError', 'error');
- }
- });
- }
-
- // Load Extracted button event listener - for loading Art-Extractor JSON files
- const loadExtractedBtn = document.getElementById('loadExtractedBtn');
- if (loadExtractedBtn) {
- loadExtractedBtn.addEventListener('click', async () => {
- try {
- updateUI('loadingImage', 'default');
- const fileData = await Utils.loadExtractedFileData();
-
- if (!fileData) {
- updateUI('ready', 'default');
- return;
- }
-
- console.log('š Loading extracted artwork from Art-Extractor...');
- console.log('š [DEBUG] Loaded file data structure:', {
- hasState: !!fileData.state,
- hasImageData: !!fileData.imageData,
- topLevelKeys: Object.keys(fileData),
- stateKeys: fileData.state ? Object.keys(fileData.state) : 'N/A',
- imageDataKeys: fileData.imageData ? Object.keys(fileData.imageData) : 'N/A',
- fileDataType: typeof fileData,
- stateType: typeof fileData.state,
- imageDataType: typeof fileData.imageData
- });
-
- // Validate the data structure before restoring
- if (!fileData || typeof fileData !== 'object') {
- throw new Error('Invalid file format: File data is not a valid object');
- }
-
- if (!fileData.state || typeof fileData.state !== 'object') {
- console.error('ā State validation failed. FileData:', fileData);
- throw new Error('Invalid file format: Missing or invalid state object. Please ensure you exported from Art-Extractor correctly.');
- }
-
- if (!fileData.imageData || typeof fileData.imageData !== 'object') {
- console.error('ā ImageData validation failed. FileData:', fileData);
- throw new Error('Invalid file format: Missing or invalid imageData object. Please ensure you completed the area scan in Art-Extractor.');
- }
-
- if (!fileData.imageData.pixels || !Array.isArray(fileData.imageData.pixels) || fileData.imageData.pixels.length === 0) {
- console.error('ā Pixel data validation failed. ImageData:', fileData.imageData);
- throw new Error('Invalid file format: No pixel data found. Please scan an area in Art-Extractor before exporting.');
- }
-
- // Ensure critical fields are present for Art-Extractor compatibility
- if (!fileData.state.availableColors) {
- console.warn('ā ļø No availableColors in file, using defaults');
- fileData.state.availableColors = [];
- }
-
- // Reset session-specific flags when loading extracted file data
- state.preFilteringDone = false;
- state.progressResetDone = false;
- console.log('š Reset session flags for extracted file load');
-
- // Use the existing restoreProgress function but with special handling
- const restoreSuccess = await Utils.restoreProgress(fileData);
-
- if (!restoreSuccess) {
- throw new Error('Failed to restore progress data');
- }
-
- // After loading, we need to enter position selection mode like when uploading images
- if (state.imageLoaded) {
- console.log('šÆ Extracted artwork loaded, entering position selection mode...');
-
- // Clear position data since Art-Extractor exports may have null positions for manual placement
- state.startPosition = null;
- state.region = null;
- state.selectingPosition = true;
-
- // For extracted files, force enable all necessary flags for full functionality
- state.colorsChecked = true;
- state.currentPaintingColor = null; // Reset color tracking
- state.imageLoaded = true;
-
- // Ensure image processor is available for extracted files
- if (state.imageData && !state.imageData.processor) {
- try {
- // Create a temporary canvas to generate a data URL for the processor
- const canvas = document.createElement('canvas');
- canvas.width = state.imageData.width;
- canvas.height = state.imageData.height;
- const ctx = canvas.getContext('2d');
-
- // Create image data from pixels
- const imageData = new ImageData(
- new Uint8ClampedArray(state.imageData.pixels),
- state.imageData.width,
- state.imageData.height
- );
- ctx.putImageData(imageData, 0, 0);
-
- // Create processor with canvas data URL using the correct class
- const dataUrl = canvas.toDataURL();
- state.imageData.processor = new window.WPlaceImageProcessor(dataUrl);
-
- // CRITICAL: Load the processor before using it
- await state.imageData.processor.load();
- console.log('š§ Created and loaded WPlaceImageProcessor for extracted artwork');
- } catch (error) {
- console.error('ā Failed to create image processor for extracted artwork:', error);
- // Fallback: use global processor if available
- if (window.globalImageProcessor) {
- console.log('š Using global image processor as fallback');
- state.imageData.processor = window.globalImageProcessor;
- }
- }
- }
-
- console.log('šØ Force-enabled all flags for extracted JSON to access resize panel');
-
- // For extracted images, ensure overlay is enabled but don't set position yet
- try {
- if (overlayManager && state.imageData) {
- // Create image bitmap from the restored image data
- const canvas = new OffscreenCanvas(state.imageData.width, state.imageData.height);
- const ctx = canvas.getContext('2d');
- const imageData = new ImageData(
- new Uint8ClampedArray(state.imageData.pixels),
- state.imageData.width,
- state.imageData.height
- );
- ctx.putImageData(imageData, 0, 0);
- const imageBitmap = await canvas.transferToImageBitmap();
-
- await overlayManager.setImage(imageBitmap);
- overlayManager.enable();
- console.log('ā
Overlay enabled for extracted artwork');
- }
- } catch (overlayError) {
- console.warn('ā ļø Could not set overlay for extracted artwork:', overlayError);
- }
-
- // For extracted images, don't try to set overlay position until user selects one
- // The overlay should already be enabled from the restoreProgress function
-
- // Update UI with custom message for extracted artwork
- Utils.showAlert('šØ Extracted artwork loaded! Please click on the canvas to select where to place it.', 'info');
- updateUI('ready', 'default'); // Use 'ready' instead of 'waitingPosition' to avoid translation issues
-
- // Enable relevant buttons
- selectPosBtn.disabled = false;
- // Always enable resize button for extracted files since they have image processor
- resizeBtn.disabled = false;
- console.log('š§ Resize button enabled for extracted artwork');
-
- // Enable move artwork button (will be fully enabled after position is set)
- const moveArtworkBtn = document.getElementById('moveArtworkBtn');
- if (moveArtworkBtn) {
- moveArtworkBtn.disabled = false;
- }
-
- // Set up position selection like the regular selectPosBtn
- const tempFetch = async (url, options) => {
- if (
- typeof url === 'string' &&
- url.includes('/s0/pixel/') &&
- options &&
- options.method === 'POST'
- ) {
- let coords;
- try {
- const body = JSON.parse(options.body);
- coords = body.coords;
- } catch (e) {
- return window.originalFetch(url, options);
- }
-
- const tileMatches = url.match(/\/s0\/pixel\/(\-?\d+)\/(\-?\d+)/);
- if (tileMatches && coords && coords.length >= 2) {
- const tileX = parseInt(tileMatches[1]);
- const tileY = parseInt(tileMatches[2]);
- const pixelX = coords[0];
- const pixelY = coords[1];
-
- state.startPosition = { x: pixelX, y: pixelY };
- state.region = { x: tileX, y: tileY };
- state.selectingPosition = false;
-
- console.log('šÆ Position selected for extracted artwork:', {
- startPosition: state.startPosition,
- region: state.region
- });
-
- // Restore original fetch
- window.fetch = window.originalFetch;
-
- // Update overlay position with validation
- try {
- if (state.startPosition && state.region && overlayManager) {
- await overlayManager.setPosition(state.startPosition, state.region);
- console.log('ā
Overlay position updated successfully');
- } else {
- console.warn('ā ļø Cannot set overlay position: missing startPosition or region');
- }
- } catch (positionError) {
- console.error('ā Failed to set overlay position:', positionError);
- }
-
- startBtn.disabled = false;
- selectPosBtn.textContent = Utils.t('selectPosition');
-
- // Enable Move Artwork button when position is set
- const moveArtworkBtn = document.getElementById('moveArtworkBtn');
- if (moveArtworkBtn) {
- moveArtworkBtn.disabled = false;
- }
-
- updateUI('ready', 'success');
- Utils.showAlert('Position selected! Ready to start painting.', 'success');
- }
- }
- return window.originalFetch(url, options);
- };
-
- // Store original fetch and replace with position capture
- if (!window.originalFetch) {
- window.originalFetch = window.fetch;
- }
- window.fetch = tempFetch;
- }
-
- } catch (error) {
- console.error('ā Failed to load extracted artwork:', error);
- Utils.showAlert(`Failed to load extracted artwork: ${error.message}`, 'error');
- updateUI('ready', 'default');
- }
- });
- }
-
- if (resizeBtn) {
- resizeBtn.addEventListener('click', () => {
- console.log('š [DEBUG] Resize button clicked. State check:', {
- imageLoaded: state.imageLoaded,
- hasProcessor: !!(state.imageData && state.imageData.processor),
- colorsChecked: state.colorsChecked,
- hasAvailableColors: !!(state.availableColors && state.availableColors.length > 0)
- });
-
- if (state.imageLoaded && state.imageData && state.imageData.processor) {
- showResizeDialog(state.imageData.processor);
- } else {
- let message = 'Please upload an image and check colors first.';
- if (!state.imageLoaded) message = 'Please upload an image first.';
- else if (!state.imageData || !state.imageData.processor) message = 'Image processor not available. Please reload the image.';
- Utils.showAlert(message, 'warning');
- }
- });
- }
-
- // Move Artwork button event listener
- const moveArtworkBtn = document.getElementById('moveArtworkBtn');
- if (moveArtworkBtn) {
- moveArtworkBtn.addEventListener('click', () => {
- if (state.imageLoaded && (state.startPosition || state.selectingPosition)) {
- showMoveArtworkPanel();
- } else if (!state.imageLoaded) {
- Utils.showAlert('Please upload or load an image first', 'warning');
- } else {
- Utils.showAlert('Please select a position for the artwork first', 'warning');
- }
- });
- }
-
- if (selectPosBtn) {
- selectPosBtn.addEventListener('click', async () => {
- if (state.selectingPosition) return;
-
- state.selectingPosition = true;
- state.startPosition = null;
- state.region = null;
- startBtn.disabled = true;
-
- // Disable Move Artwork button when selecting new position
- const moveArtworkBtn = document.getElementById('moveArtworkBtn');
- if (moveArtworkBtn) {
- moveArtworkBtn.disabled = true;
- }
-
- Utils.showAlert(Utils.t('selectPositionAlert'), 'info');
- updateUI('waitingPosition', 'default');
-
- const tempFetch = async (url, options) => {
- if (
- typeof url === 'string' &&
- url.includes('https://backend.wplace.live/s0/pixel/') &&
- options?.method?.toUpperCase() === 'POST'
- ) {
- try {
- const response = await originalFetch(url, options);
- const clonedResponse = response.clone();
- const data = await clonedResponse.json();
-
- if (data?.painted === 1) {
- const regionMatch = url.match(/\/pixel\/(\d+)\/(\d+)/);
- if (regionMatch && regionMatch.length >= 3) {
- state.region = {
- x: Number.parseInt(regionMatch[1]),
- y: Number.parseInt(regionMatch[2]),
- };
- }
-
- const payload = JSON.parse(options.body);
- if (payload?.coords && Array.isArray(payload.coords)) {
- state.startPosition = {
- x: payload.coords[0],
- y: payload.coords[1],
- };
- // Keep existing lastPosition to continue from where we left off
- // state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
-
- // Update overlay position with validation
- try {
- if (state.startPosition && state.region && overlayManager) {
- await overlayManager.setPosition(state.startPosition, state.region);
- console.log('ā
Regular overlay position updated successfully');
- } else {
- console.warn('ā ļø Cannot set regular overlay position: missing startPosition or region');
- }
- } catch (positionError) {
- console.error('ā Failed to set regular overlay position:', positionError);
- }
-
- if (state.imageLoaded) {
- startBtn.disabled = false;
-
- // Enable Move Artwork button when position is set
- const moveArtworkBtn = document.getElementById('moveArtworkBtn');
- if (moveArtworkBtn) {
- moveArtworkBtn.disabled = false;
- }
- }
-
- window.fetch = originalFetch;
- state.selectingPosition = false;
- updateUI('positionSet', 'success');
- }
- }
-
- return response;
- } catch {
- return originalFetch(url, options);
- }
- }
- return originalFetch(url, options);
- };
-
- const originalFetch = window.fetch;
- window.fetch = tempFetch;
-
- setTimeout(() => {
- if (state.selectingPosition) {
- window.fetch = originalFetch;
- state.selectingPosition = false;
- updateUI('positionTimeout', 'error');
- Utils.showAlert(Utils.t('positionTimeout'), 'error');
- }
- }, 120000);
- });
- }
-
- async function startPainting() {
- if (!state.imageLoaded || !state.startPosition || !state.region) {
- updateUI('missingRequirements', 'error');
- return;
- }
- await ensureToken();
- if (!getTurnstileToken()) return;
-
- // Only perform progressive pixel detection on first start of session
- if (!state.preFilteringDone) {
- // Perform progressive pixel detection from top-left to bottom-right
- console.log('š Starting progressive pixel detection from top-left to bottom-right...');
- await performProgressivePixelDetection();
-
- // Mark pre-filtering as done to prevent duplicate scanning
- state.preFilteringDone = true;
- console.log('ā
Pre-filtering marked as complete - will not scan again this session');
- } else {
- console.log('š Continuing session - pre-filtering already done');
- }
-
- state.running = true;
- state.stopFlag = false;
- startBtn.disabled = true;
- stopBtn.disabled = false;
- uploadBtn.disabled = true;
- selectPosBtn.disabled = true;
- resizeBtn.disabled = true;
- saveBtn.disabled = true;
- toggleOverlayBtn.disabled = true;
-
- updateUI('startPaintingMsg', 'success');
-
- try {
- await getAccounts();
- await processImage();
- } catch (e) {
- console.error('Unexpected error:', e);
- updateUI('paintingError', 'error');
- } finally {
- state.running = false;
- stopBtn.disabled = true;
- saveBtn.disabled = false;
-
- if (state.stopFlag) {
- startBtn.disabled = false;
- } else {
- startBtn.disabled = true;
- uploadBtn.disabled = false;
- const loadExtractedBtn = document.getElementById('loadExtractedBtn');
- if (loadExtractedBtn) loadExtractedBtn.disabled = false;
- selectPosBtn.disabled = false;
- resizeBtn.disabled = false;
- }
- toggleOverlayBtn.disabled = false;
- }
- }
-
- if (startBtn) {
- startBtn.addEventListener('click', startPainting);
- }
-
- if (stopBtn) {
- stopBtn.addEventListener('click', () => {
- state.stopFlag = true;
- state.running = false;
- stopBtn.disabled = true;
- updateUI('paintingStoppedByUser', 'warning');
-
- if (state.imageLoaded && state.paintedPixels > 0) {
- Utils.saveProgress();
- Utils.showAlert(Utils.t('autoSaved'), 'success');
- }
- });
- }
-
- const checkSavedProgress = () => {
- const savedData = Utils.loadProgress();
- if (savedData && savedData.state.paintedPixels > 0) {
- const savedDate = new Date(savedData.timestamp).toLocaleString();
- const progress = Math.round(
- (savedData.state.paintedPixels / savedData.state.totalPixels) * 100
- );
-
- Utils.showAlert(
- `${Utils.t('savedDataFound')}\n\n` +
- `Saved: ${savedDate}\n` +
- `Progress: ${savedData.state.paintedPixels}/${savedData.state.totalPixels} pixels (${progress}%)\n` +
- `${Utils.t('clickLoadToContinue')}`,
- 'info'
- );
- }
- };
-
- setTimeout(checkSavedProgress, 1000);
-
- if (cooldownSlider && cooldownInput && cooldownValue && cooldownDecrease && cooldownIncrease) {
- const updateCooldown = (newValue) => {
- const threshold = Math.max(1, Math.min(state.maxCharges || 999, parseInt(newValue)));
- state.cooldownChargeThreshold = threshold;
-
- // Update both controls (value shows in input, label shows unit only)
- cooldownSlider.value = threshold;
- cooldownInput.value = threshold;
- cooldownValue.textContent = `${Utils.t('charges')}`;
-
- saveBotSettings();
- NotificationManager.resetEdgeTracking(); // prevent spurious notify after threshold change
- };
-
- // Slider event listener
- cooldownSlider.addEventListener('input', (e) => {
- updateCooldown(e.target.value);
- });
-
- // Number input event listener
- cooldownInput.addEventListener('input', (e) => {
- updateCooldown(e.target.value);
- });
-
- // Decrease button
- cooldownDecrease.addEventListener('click', () => {
- updateCooldown(parseInt(cooldownInput.value) - 1);
- });
-
- // Increase button
- cooldownIncrease.addEventListener('click', () => {
- updateCooldown(parseInt(cooldownInput.value) + 1);
- });
-
- // Add scroll-to-adjust for cooldown slider
- Utils.createScrollToAdjust(cooldownSlider, updateCooldown, 1, state.maxCharges, 1);
-
- // Skip cooldown button event handler
- const skipCooldownBtn = document.getElementById('skipCooldownBtn');
- if (skipCooldownBtn) {
- skipCooldownBtn.addEventListener('click', () => {
- if (state.preciseCurrentCharges < state.cooldownChargeThreshold) {
- console.log(`[Auto-Image] Skip cooldown requested - resetting to account index 0`);
-
- // Reset to account index 0 (start new cycle)
- state.currentActiveIndex = 0;
- console.log(`š Reset currentActiveIndex to 0 for new cycle`);
-
- // Reset charges to threshold to bypass cooldown
- state.preciseCurrentCharges = state.cooldownChargeThreshold;
- console.log(`[Auto-Image] Cooldown skipped! Charges set to threshold: ${state.cooldownChargeThreshold}`);
-
- // Switch to first account if we have multiple accounts
- if (accountManager.getAccountCount() > 1) {
- const firstAccountInfo = accountManager.getAccountByIndex(0);
- if (firstAccountInfo && firstAccountInfo.token) {
- console.log(`š Switching to first account: index 0 (${firstAccountInfo.displayName})`);
- // Get accounts array for the switch
- const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
- if (accounts.length > 0) {
- switchToSpecificAccount(firstAccountInfo.token, firstAccountInfo.displayName).then(() => {
- console.log(`ā
Successfully switched to account index 0 after cooldown skip`);
- // Update UI immediately
- updateChargesThresholdUI(0);
- // Trigger immediate check for painting
- if (state.isEnabled && !state.stopFlag) {
- setTimeout(() => {
- checkAndPaint();
- }, 100);
- }
- }).catch(err => {
- console.error(`ā Failed to switch to account index 0:`, err);
- });
- } else {
- console.warn(`ā ļø No accounts available for switching`);
- }
- } else {
- console.warn(`ā ļø First account info not found or missing token`);
- }
- } else {
- console.log(`š Single account mode - no switching needed`);
- // Update UI immediately
- updateChargesThresholdUI(0);
- // Trigger immediate check for painting
- if (state.isEnabled && !state.stopFlag) {
- setTimeout(() => {
- checkAndPaint();
- }, 100);
- }
- }
- }
- });
- }
- }
-
- loadBotSettings();
- // Ensure notification poller reflects current settings
- NotificationManager.syncFromState();
- }
-
- function getMsToTargetCharges(current, target, cooldown, intervalMs = 0) {
- const remainingCharges = target - current;
- return Math.max(0, remainingCharges * cooldown - intervalMs);
- }
-
- function updateChargesThresholdUI(intervalMs) {
- if (state.stopFlag) return;
-
- const threshold = state.cooldownChargeThreshold;
- const remainingMs = getMsToTargetCharges(
- state.preciseCurrentCharges,
- threshold,
- state.cooldown,
- intervalMs
- );
- const timeText = Utils.msToTimeText(remainingMs);
-
- updateUI(
- 'noChargesThreshold',
- 'warning',
- {
- threshold,
- current: state.displayCharges,
- time: timeText,
- },
- true
- );
-
- // Update skip cooldown button state
- const skipCooldownBtn = document.getElementById('skipCooldownBtn');
- if (skipCooldownBtn) {
- const isInCooldown = state.preciseCurrentCharges < threshold && remainingMs > 0;
- skipCooldownBtn.disabled = !isInCooldown;
- skipCooldownBtn.title = isInCooldown
- ? `Skip cooldown (${timeText} remaining)`
- : 'Skip cooldown (only available during cooldown)';
- }
- }
-
- // Fast tile-based pixel detection from top-left to bottom-right
- async function performProgressivePixelDetection() {
- if (!state.imageLoaded || !state.imageData) {
- console.log('ā ļø No image loaded, skipping pixel detection');
- return;
- }
-
- const startTime = performance.now();
- const { width, height } = state.imageData;
- const startX = state.startPosition.x;
- const startY = state.startPosition.y;
- const regionX = state.region.x;
- const regionY = state.region.y;
-
- let detectedPixels = 0;
- let totalChecked = 0;
-
- console.log(`š Fast scanning ${width}x${height} image from top-left (0,0) to bottom-right (${width - 1},${height - 1})...`);
-
- updateUI('pixelDetection', 'info', { message: 'Fast detecting already painted pixels...' });
-
- // Calculate affected tiles
- const worldX1 = startX;
- const worldY1 = startY;
- const worldX2 = startX + width - 1;
- const worldY2 = startY + height - 1;
-
- const startTileX = Math.floor(worldX1 / 1000);
- const startTileY = Math.floor(worldY1 / 1000);
- const endTileX = Math.floor(worldX2 / 1000);
- const endTileY = Math.floor(worldY2 / 1000);
-
- console.log(`š Processing tiles from (${startTileX},${startTileY}) to (${endTileX},${endTileY})`);
-
- // Cache for downloaded tile data
- const tileDataCache = new Map();
-
- // Download and cache all required tiles in parallel
- const tilePromises = [];
- for (let tileY = startTileY; tileY <= endTileY; tileY++) {
- for (let tileX = startTileX; tileX <= endTileX; tileX++) {
- const absoluteTileX = regionX + tileX;
- const absoluteTileY = regionY + tileY;
- const tileKey = `${absoluteTileX},${absoluteTileY}`;
-
- tilePromises.push(
- downloadTileImageData(absoluteTileX, absoluteTileY).then(imageData => {
- if (imageData) {
- tileDataCache.set(tileKey, imageData);
- }
- }).catch(e => {
- console.warn(`ā ļø Failed to download tile ${absoluteTileX},${absoluteTileY}:`, e.message);
- })
- );
- }
- }
-
- // Wait for all tiles to download
- await Promise.all(tilePromises);
- console.log(`š¦ Downloaded ${tileDataCache.size} tiles for fast pixel checking`);
-
- // Fast pixel detection using cached tile data
- for (let y = 0; y < height; y++) {
- for (let x = 0; x < width; x++) {
- totalChecked++;
-
- // Check if pixel is eligible for painting
- const targetPixelInfo = checkPixelEligibility(x, y);
- if (!targetPixelInfo.eligible) {
- continue; // Skip non-eligible pixels
- }
-
- // Calculate absolute world coordinates
- const absX = startX + x;
- const absY = startY + y;
- const adderX = Math.floor(absX / 1000);
- const adderY = Math.floor(absY / 1000);
- const pixelX = absX % 1000;
- const pixelY = absY % 1000;
- const absoluteTileX = regionX + adderX;
- const absoluteTileY = regionY + adderY;
-
- // Check if already marked as painted in local map
- if (Utils.isPixelPainted(x, y, absoluteTileX, absoluteTileY)) {
- detectedPixels++;
- continue;
- }
-
- // Fast pixel color check using cached tile data
- const tileKey = `${absoluteTileX},${absoluteTileY}`;
- const tileImageData = tileDataCache.get(tileKey);
-
- if (tileImageData) {
- try {
- // Direct array access - much faster than canvas operations
- const tileWidth = tileImageData.width;
- const tileHeight = tileImageData.height;
- const data = tileImageData.data;
-
- // Ensure pixel coordinates are within tile bounds
- if (pixelX >= 0 && pixelX < tileWidth && pixelY >= 0 && pixelY < tileHeight) {
- const pixelIndex = (pixelY * tileWidth + pixelX) * 4;
- const r = data[pixelIndex];
- const g = data[pixelIndex + 1];
- const b = data[pixelIndex + 2];
- const a = data[pixelIndex + 3];
-
- // Check alpha threshold
- const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
- if (a >= alphaThresh) {
- const existingMappedColor = Utils.resolveColor(
- [r, g, b],
- state.availableColors,
- !state.paintUnavailablePixels
- );
- const isAlreadyPainted = existingMappedColor.id === targetPixelInfo.mappedColorId;
-
- if (isAlreadyPainted) {
- // Check if pixel is already marked as painted to avoid double counting
- if (!Utils.isPixelPainted(x, y, absoluteTileX, absoluteTileY)) {
- // Mark as painted in the map but DO NOT increment progress counter
- // Progress counter should only reflect actual painting sequence position
- Utils.markPixelPainted(x, y, absoluteTileX, absoluteTileY);
- detectedPixels++;
- } else {
- // Pixel already tracked, just count it for detection stats
- detectedPixels++;
- }
- }
- }
- }
- } catch (e) {
- // Skip pixels we can't check
- console.warn(`ā ļø Could not check pixel (${x}, ${y}):`, e.message);
- }
- }
-
- // Update progress periodically
- if (totalChecked % 2500 === 0) {
- const progress = Math.round((totalChecked / (width * height)) * 100);
- updateUI('pixelDetection', 'info', {
- message: `Fast detecting... ${progress}% (Found: ${detectedPixels})`
- });
- // Yield control briefly to prevent UI blocking
- await new Promise(resolve => setTimeout(resolve, 0));
- }
- }
- }
-
- const processingTime = Math.round(performance.now() - startTime);
- console.log(`š Fast pixel detection complete in ${processingTime}ms:`);
- console.log(` - Total pixels checked: ${totalChecked}`);
- console.log(` - Already painted pixels found: ${detectedPixels}`);
- console.log(` - Updated progress: ${state.paintedPixels}/${state.totalPixels}`);
- console.log(` - Performance: ${Math.round(totalChecked / (processingTime / 1000))} pixels/second`);
-
- // Update progress display
- await updateStats();
- updateUI('pixelDetectionComplete', 'success', {
- message: `Found ${detectedPixels} already painted pixels in ${processingTime}ms`
- });
- }
-
- // Fast tile download and ImageData extraction (similar to Art-Extractor approach)
- async function downloadTileImageData(tileX, tileY) {
- try {
- const tileUrl = `https://backend.wplace.live/files/s0/tiles/${tileX}/${tileY}.png`;
- const response = await fetch(tileUrl);
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
-
- const blob = await response.blob();
- return await processTileBlob(blob);
- } catch (error) {
- console.warn(`Failed to download tile ${tileX},${tileY}:`, error.message);
- return null;
- }
- }
-
- // Process tile blob into ImageData (adapted from Art-Extractor)
- async function processTileBlob(blob) {
- try {
- const img = new Image();
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
-
- return new Promise((resolve, reject) => {
- img.onload = () => {
- canvas.width = img.width;
- canvas.height = img.height;
- ctx.imageSmoothingEnabled = false;
- ctx.drawImage(img, 0, 0);
- const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
- resolve(imageData);
- };
- img.onerror = () => reject(new Error('Failed to load image'));
- img.src = URL.createObjectURL(blob);
- });
- } catch (error) {
- console.error('Error processing tile blob:', error);
- return null;
- }
- }
-
- function generateCoordinates(width, height, mode, direction, snake, blockWidth, blockHeight, startFromX = 0, startFromY = 0) {
- const coords = [];
- console.log(
- 'Generating coordinates with \n mode:',
- mode,
- '\n direction:',
- direction,
- '\n snake:',
- snake,
- '\n blockWidth:',
- blockWidth,
- '\n blockHeight:',
- blockHeight,
- '\n startFromX:',
- startFromX,
- '\n startFromY:',
- startFromY
- );
- // --------- Standard 4 corners traversal ----------
- let xStart, xEnd, xStep;
- let yStart, yEnd, yStep;
- switch (direction) {
- case 'top-left':
- xStart = 0;
- xEnd = width;
- xStep = 1;
- yStart = 0;
- yEnd = height;
- yStep = 1;
- break;
- case 'top-right':
- xStart = width - 1;
- xEnd = -1;
- xStep = -1;
- yStart = 0;
- yEnd = height;
- yStep = 1;
- break;
- case 'bottom-left':
- xStart = 0;
- xEnd = width;
- xStep = 1;
- yStart = height - 1;
- yEnd = -1;
- yStep = -1;
- break;
- case 'bottom-right':
- xStart = width - 1;
- xEnd = -1;
- xStep = -1;
- yStart = height - 1;
- yEnd = -1;
- yStep = -1;
- break;
- default:
- throw new Error(`Unknown direction: ${direction}`);
- }
-
- // --------- Traversal modes ----------
- if (mode === 'rows') {
- let rowIndex = 0;
- for (let y = yStart; y !== yEnd; y += yStep) {
- if (snake && rowIndex % 2 !== 0) {
- for (let x = xEnd - xStep; x !== xStart - xStep; x -= xStep) {
- coords.push([x, y]);
- }
- } else {
- for (let x = xStart; x !== xEnd; x += xStep) {
- coords.push([x, y]);
- }
- }
- rowIndex++;
- }
- } else if (mode === 'columns') {
- let colIndex = 0;
- for (let x = xStart; x !== xEnd; x += xStep) {
- if (snake && colIndex % 2 !== 0) {
- for (let y = yEnd - yStep; y !== yStart - yStep; y -= yStep) {
- coords.push([x, y]);
- }
- } else {
- for (let y = yStart; y !== yEnd; y += yStep) {
- coords.push([x, y]);
- }
- }
- colIndex++;
- }
- } else if (mode === 'circle-out') {
- const cx = Math.floor(width / 2);
- const cy = Math.floor(height / 2);
- const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
-
- for (let r = 0; r <= maxRadius; r++) {
- for (let y = cy - r; y <= cy + r; y++) {
- for (let x = cx - r; x <= cx + r; x++) {
- if (x >= 0 && x < width && y >= 0 && y < height) {
- const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
- if (dist === r) coords.push([x, y]);
- }
- }
- }
- }
- } else if (mode === 'circle-in') {
- const cx = Math.floor(width / 2);
- const cy = Math.floor(height / 2);
- const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
-
- for (let r = maxRadius; r >= 0; r--) {
- for (let y = cy - r; y <= cy + r; y++) {
- for (let x = cx - r; x <= cx + r; x++) {
- if (x >= 0 && x < width && y >= 0 && y < height) {
- const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
- if (dist === r) coords.push([x, y]);
- }
- }
- }
- }
- } else if (mode === 'blocks' || mode === 'shuffle-blocks') {
- const blocks = [];
- for (let by = 0; by < height; by += blockHeight) {
- for (let bx = 0; bx < width; bx += blockWidth) {
- const block = [];
- for (let y = by; y < Math.min(by + blockHeight, height); y++) {
- for (let x = bx; x < Math.min(bx + blockWidth, width); x++) {
- block.push([x, y]);
- }
- }
- blocks.push(block);
- }
- }
-
- if (mode === 'shuffle-blocks') {
- // Simple Fisher-Yates shuffle
- for (let i = blocks.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [blocks[i], blocks[j]] = [blocks[j], blocks[i]];
- }
- }
-
- // Concatenate all blocks
- for (const block of blocks) {
- coords.push(...block);
- }
- } else {
- throw new Error(`Unknown mode: ${mode}`);
- }
-
- // Filter coordinates to start from the specified position
- if (startFromX > 0 || startFromY > 0) {
- console.log(`š Filtering coordinates to resume from position (${startFromX}, ${startFromY})`);
- let startIndex = -1;
-
- // Find the starting position in the coordinate list
- for (let i = 0; i < coords.length; i++) {
- const [x, y] = coords[i];
- if (x === startFromX && y === startFromY) {
- startIndex = i;
- break;
- }
- }
-
- if (startIndex >= 0) {
- // Resume from the found position (skip all previous coordinates)
- const filteredCoords = coords.slice(startIndex);
- console.log(`āļø Resuming: skipped ${startIndex} coordinates, continuing with ${filteredCoords.length} remaining`);
- return filteredCoords;
- } else {
- console.warn(`ā ļø Resume position (${startFromX}, ${startFromY}) not found in coordinate list, starting from beginning`);
- }
- }
-
- return coords;
- }
-
- async function flushPixelBatch(pixelBatch) {
- if (!pixelBatch || pixelBatch.pixels.length === 0) return true;
-
- const batchSize = pixelBatch.pixels.length;
- console.log(
- `š¦ Sending batch with ${batchSize} pixels (region: ${pixelBatch.regionX},${pixelBatch.regionY})`
- );
- const success = await sendBatchWithRetry(
- pixelBatch.pixels,
- pixelBatch.regionX,
- pixelBatch.regionY
- );
- if (success) {
- // Only increment progress for actually painted pixels to prevent multiplication
- const actuallyPaintedCount = pixelBatch.pixels.length;
- state.paintedPixels += actuallyPaintedCount;
- console.log(`š Added ${actuallyPaintedCount} painted pixels to progress (total: ${state.paintedPixels})`);
-
- pixelBatch.pixels.forEach((p) => {
- Utils.markPixelPainted(p.x, p.y, pixelBatch.regionX, pixelBatch.regionY);
- });
-
- // Update last painted position to the last pixel in the successful batch
- if (pixelBatch.pixels.length > 0) {
- const lastPixel = pixelBatch.pixels[pixelBatch.pixels.length - 1];
- // FIXED: Use localX/localY (image-relative coordinates) instead of x/y (absolute canvas coordinates)
- state.lastPaintedPosition = { x: lastPixel.localX, y: lastPixel.localY };
- }
-
- // IMPORTANT: Decrement charges locally to match Acc-Switch.js behavior
- state.displayCharges = Math.max(0, state.displayCharges - batchSize);
- state.preciseCurrentCharges = Math.max(0, state.preciseCurrentCharges - batchSize);
-
- state.fullChargeData = {
- ...state.fullChargeData,
- spentSinceShot: state.fullChargeData.spentSinceShot + batchSize,
- };
- await updateStats();
- // Update account list with new charges
- await updateCurrentAccountInList();
- // Progress tracking removed from UI to reduce visual clutter
- Utils.performSmartSave();
-
- if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && batchSize > 0) {
- const delayPerPixel = 1000 / state.paintingSpeed;
- const totalDelay = Math.max(100, delayPerPixel * batchSize);
- await Utils.sleep(totalDelay);
- }
- } else {
- console.error(`ā Batch failed permanently after retries. Stopping painting.`);
- state.stopFlag = true;
- updateUI('paintingBatchFailed', 'error');
- }
-
- pixelBatch.pixels = [];
- return success;
- }
-
- async function processImage() {
- console.log('š Starting auto-swap enabled painting workflow');
-
- try {
- // Main painting cycle - repeats until image complete or stopped
- while (!state.stopFlag) {
- console.log('š Phase 1: Starting painting session');
- const paintingResult = await executePaintingSession();
-
- if (paintingResult === 'completed') {
- console.log('š Image painting completed!');
- state.currentPaintingColor = null; // Reset color tracking
- break;
- }
-
- if (paintingResult === 'stopped') {
- console.log('ā¹ļø Painting stopped by user');
- break;
- }
-
- if (paintingResult === 'charges_depleted') {
- if (CONFIG.autoBuyToggle && CONFIG.autoBuy != 'none') {
- console.log('Trying to buy more charges before swapping account...');
- const purchaseResult = await purchase(CONFIG.autoBuy);
- if (purchaseResult == 2) {
- console.log('ā
Purchase successful, continuing painting');
- await updateStats();
- await updateCurrentAccountInList();
- if (CONFIG.autoBuy == 'paint_charges') continue;
- }
- else if (purchaseResult == 1) {
- console.log('š Not enough droplets to buy more charges, swapping account.');
- }
- else {
- console.log('š¤ Purchase failed.');
- }
- }
- else console.log('𤫠Auto buy is disabled, wait for cooldown or swapping account.');
- if (!CONFIG.autoSwap) {
- // Original workflow: cooldown period
- console.log('ā±ļø Phase 2: Entering cooldown period (auto-swap disabled)');
- const cooldownResult = await executeCooldownPeriod();
-
- if (cooldownResult === 'stopped') {
- console.log('ā¹ļø Cooldown stopped by user');
- break;
- }
-
- // Phase 3: Regenerate token for next painting session
- console.log('š Phase 3: Regenerating token for next session');
- const tokenResult = await regenerateTokenForNewSession();
-
- if (!tokenResult) {
- console.log('ā Failed to regenerate token, stopping');
- state.stopFlag = true;
- break;
- }
- } else {
- // Auto-swap workflow: simplified logic
- console.log('š Auto-swap enabled: checking account switching options');
-
- // Retrieve fresh accounts list each time to avoid stale data
- const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
- console.log(`š Retrieved ${accounts.length} accounts from localStorage`);
-
- if (accounts.length <= 1) {
- console.log('š Only one account available, using standard cooldown');
- const cooldownResult = await executeCooldownPeriod();
- if (cooldownResult === 'stopped') break;
-
- const tokenResult = await regenerateTokenForNewSession();
- if (!tokenResult) {
- state.stopFlag = true;
- break;
- }
- } else {
- // Debug current state
- const totalAccounts = accountManager.getAccountCount();
- console.log(`š Account Status - Current index: ${accountManager.currentIndex}, Total accounts: ${totalAccounts}`);
-
- // Check if we're at the last account in the sequence
- const isLastAccount = accountManager.currentIndex >= totalAccounts - 1;
- console.log(`š Is last account? ${isLastAccount} (index ${accountManager.currentIndex} of ${totalAccounts})`);
-
- if (!isLastAccount && totalAccounts > 1) {
- // Update current account status before switching
- console.log('š Updating current account status before switch...');
- await updateCurrentAccountInList();
-
- // Switch to next account immediately (no cooldown) - only if we have multiple accounts
- const nextAccount = accountManager.getNextAccount();
- console.log(`š Switching to next account: ${nextAccount?.displayName} (${accountManager.currentIndex + 2}/${totalAccounts})`);
- const switchResult = await switchToNextAccount(accounts);
- if (!switchResult) {
- console.log('ā Account switch failed, stopping');
- state.stopFlag = true;
- break;
- }
- // Continue painting with new account immediately
- continue;
- } else if (totalAccounts === 1) {
- // Only one account available - use cooldown and continue with same account
- console.log('ā¹ļø Only one account available, entering cooldown period');
- const cooldownResult = await executeCooldownPeriod();
- if (cooldownResult === 'stopped') break;
- console.log('ā
Cooldown complete, continuing with same account');
- continue;
- } else {
- // Last account reached - use cooldown then switch to first account
- console.log('ā±ļø Last account reached, entering cooldown period');
- console.log(`š Current account: index ${accountManager.currentIndex}, Last account: index ${totalAccounts - 1}`);
-
- const cooldownResult = await executeCooldownPeriod();
- if (cooldownResult === 'stopped') break;
-
- // After cooldown, switch to first account
- console.log('š Cooldown complete, switching to first account');
- console.log(`š Before switch - Current index: ${accountManager.currentIndex}, Target: index 0`);
-
- const firstAccountInfo = accountManager.getAccountByIndex(0);
- const firstAccountToken = firstAccountInfo?.token;
-
- if (!firstAccountToken) {
- console.log('ā First account token not found, stopping');
- state.stopFlag = true;
- break;
- }
-
- // Reset to first position
- accountManager.setCurrentIndex(0);
-
- const switchResult = await switchToSpecificAccount(firstAccountToken, firstAccountInfo.displayName);
- if (!switchResult) {
- console.log('ā Switch to ID 1 failed, stopping');
- state.stopFlag = true;
- break;
- }
-
- console.log(`ā
Successfully switched to ID 1 (${firstAccountInfo.displayName}). Cycle restarted.`);
- // Continue painting with first account
- continue;
- }
- }
- }
- }
-
- console.log('š Cycle complete, starting next painting session');
- }
- } finally {
- await finalizePaintingProcess();
- }
- }
-
- // Phase 1: Execute a complete painting session using all available charges
- async function executePaintingSession() {
- console.log('šØ Starting painting session - using all charges until 0');
- const { width, height, pixels } = state.imageData;
- const { x: startX, y: startY } = state.startPosition;
- const { x: regionX, y: regionY } = state.region;
-
- // Check if we're working with restored data by looking for existing availableColors
- const isRestoredData = state.availableColors && state.availableColors.length > 0 && state.colorsChecked;
-
- if (!isRestoredData) {
- // Wait for original tiles to load if needed
- const tilesReady = await overlayManager.waitForTiles(
- regionX,
- regionY,
- width,
- height,
- startX,
- startY,
- 10000 // timeout 10s
- );
-
- if (!tilesReady) {
- updateUI('overlayTilesNotLoaded', 'error');
- state.stopFlag = true;
- return 'stopped';
- }
- }
-
- let pixelBatch = null;
- let skippedPixels = {
- transparent: 0,
- white: 0,
- alreadyPainted: 0,
- colorUnavailable: 0,
- };
-
- // IMPORTANT: Check charges once at start, then paint until depleted
- console.log('š Checking initial charges for painting session');
- const initialChargeCheck = await WPlaceService.getCharges();
- state.displayCharges = Math.floor(initialChargeCheck.charges);
- state.preciseCurrentCharges = initialChargeCheck.charges;
- state.cooldown = initialChargeCheck.cooldown;
- await updateStats();
- if (state.displayCharges <= 0) {
- console.log('ā” No charges available, skipping painting session');
- return 'charges_depleted';
- }
-
- console.log(`š Starting with ${state.displayCharges} charges - painting until depleted`);
-
- // Paint pixels until we run out of charges or complete the image
- try {
- // First generate coordinates without filtering to count skipped ones
- const allCoords = generateCoordinates(
- width,
- height,
- state.coordinateMode,
- state.coordinateDirection,
- state.coordinateSnake,
- state.blockWidth,
- state.blockHeight,
- 0, // Don't filter here
- 0 // Don't filter here
- );
-
- // Generate the actual filtered coordinates for painting (resuming from last position)
- const coords = generateCoordinates(
- width,
- height,
- state.coordinateMode,
- state.coordinateDirection,
- state.coordinateSnake,
- state.blockWidth,
- state.blockHeight,
- state.lastPosition.x,
- state.lastPosition.y
- );
-
- // OPTIMIZATION: Pre-filter already painted pixels (happens only once per session)
- let eligibleCoords = [];
- let alreadyPaintedCount = 0;
-
- if (!state.preFilteringDone) {
- console.log('š Pre-filtering already painted pixels (one-time detection for this session)...');
-
- for (const [x, y] of coords) {
- const targetPixelInfo = checkPixelEligibility(x, y);
-
- if (!targetPixelInfo.eligible) {
- if (targetPixelInfo.reason !== 'alreadyPainted') {
- skippedPixels[targetPixelInfo.reason]++;
-
- }
- continue;
- }
-
- // Check if already painted (only once per session)
- let absX = startX + x;
- let absY = startY + y;
- let adderX = Math.floor(absX / 1000);
- let adderY = Math.floor(absY / 1000);
- let pixelX = absX % 1000;
- let pixelY = absY % 1000;
-
- try {
- const tilePixelRGBA = await overlayManager.getTilePixelColor(
- regionX + adderX,
- regionY + adderY,
- pixelX,
- pixelY
- );
-
- if (tilePixelRGBA && Array.isArray(tilePixelRGBA)) {
- const mappedCanvasColor = Utils.resolveColor(
- tilePixelRGBA.slice(0, 3),
- state.availableColors,
- !state.paintUnavailablePixels // Use same parameter as target pixel
- );
- const isMatch = mappedCanvasColor.id === targetPixelInfo.mappedColorId;
- if (isMatch) {
- alreadyPaintedCount++;
- // Mark as painted in map but DO NOT increment progress counter
- // Progress should only reflect actual painting sequence position
- Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
- continue; // Skip already painted pixels
- }
- }
- } catch (e) {
- // If we can't check, include the pixel (better to attempt than skip)
- }
-
- // Add eligible unpainted pixel to list
- eligibleCoords.push([x, y, targetPixelInfo]);
- }
-
- // Mark pre-filtering as done for this session
- state.preFilteringDone = true;
-
- // Log pre-filtering results
- if (alreadyPaintedCount > 0) {
- console.log(`ā Pre-filter complete: ${alreadyPaintedCount} already painted pixels detected (not added to progress counter)`);
- console.log('ā¹ļø This detection will not happen again until a new image/save is loaded');
- console.log('š Progress counter only reflects actual painting sequence position');
- // No need to update stats since progress wasn't changed
- }
- skippedPixels.alreadyPainted = alreadyPaintedCount;
- } else {
- // Pre-filtering already done this session, just filter for basic eligibility
- console.log('š Using existing pre-filter results (already done this session)');
- for (const [x, y] of coords) {
- const targetPixelInfo = checkPixelEligibility(x, y);
-
- if (!targetPixelInfo.eligible) {
- if (targetPixelInfo.reason !== 'alreadyPainted') {
- skippedPixels[targetPixelInfo.reason]++;
- }
- continue;
- }
-
- // Only include pixels that haven't been marked as painted yet
- let absX = startX + x;
- let absY = startY + y;
- let adderX = Math.floor(absX / 1000);
- let adderY = Math.floor(absY / 1000);
-
- if (!Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
- eligibleCoords.push([x, y, targetPixelInfo]);
- }
- }
- }
-
- // Group pixels by color if color-by-color mode is enabled
- let pixelsToProcess = eligibleCoords;
- if (state.paintingOrder === 'color-by-color') {
- console.log('šØ Color-by-color mode enabled - grouping pixels by color');
-
- // Group pixels by color ID
- const colorGroups = new Map();
- for (const [x, y, targetPixelInfo] of eligibleCoords) {
- const colorId = targetPixelInfo.mappedColorId;
- if (!colorGroups.has(colorId)) {
- colorGroups.set(colorId, []);
- }
- colorGroups.get(colorId).push([x, y, targetPixelInfo]);
- }
-
- // Log color groups
- console.log(`š Found ${colorGroups.size} different colors to paint:`);
- for (const [colorId, pixels] of colorGroups.entries()) {
- const colorInfo = Object.values(CONFIG.COLOR_MAP).find(c => c.id === colorId);
- const colorName = colorInfo ? colorInfo.name : `Color ${colorId}`;
- console.log(` šØ ${colorName} (ID: ${colorId}): ${pixels.length} pixels`);
- }
-
- // Process colors one by one
- const sortedColorGroups = Array.from(colorGroups.entries()).sort((a, b) => a[0] - b[0]);
-
- // If we have a current painting color, resume from that color
- let startIndex = 0;
- if (state.currentPaintingColor !== null) {
- startIndex = sortedColorGroups.findIndex(([colorId]) => colorId === state.currentPaintingColor);
- if (startIndex === -1) startIndex = 0;
- console.log(`š Resuming from color ID ${state.currentPaintingColor} (index ${startIndex})`);
- }
-
- // Flatten the groups starting from the current color
- pixelsToProcess = [];
- for (let i = startIndex; i < sortedColorGroups.length; i++) {
- const [colorId, pixels] = sortedColorGroups[i];
- pixelsToProcess.push(...pixels);
- }
-
- console.log(`ā
Prepared ${pixelsToProcess.length} pixels for color-by-color painting`);
- }
-
- // Paint eligible pixels (already pre-filtered, no duplicate checks)
- outerLoop: for (const [x, y, targetPixelInfo] of pixelsToProcess) {
- // Track current color being painted in color-by-color mode
- if (state.paintingOrder === 'color-by-color') {
- if (state.currentPaintingColor !== targetPixelInfo.mappedColorId) {
- state.currentPaintingColor = targetPixelInfo.mappedColorId;
- const colorInfo = Object.values(CONFIG.COLOR_MAP).find(c => c.id === state.currentPaintingColor);
- const colorName = colorInfo ? colorInfo.name : `Color ${state.currentPaintingColor}`;
- console.log(`šØ Now painting: ${colorName} (ID: ${state.currentPaintingColor})`);
-
- // Update UI to show current color
- const statusDiv = document.getElementById('statusDiv');
- if (statusDiv) {
- const colorMessage = Utils.t('currentlyPaintingColor', { colorName });
- const colorIndicator = document.getElementById('currentColorIndicator');
- if (colorIndicator) {
- colorIndicator.textContent = colorMessage;
- } else {
- const indicator = document.createElement('div');
- indicator.id = 'currentColorIndicator';
- indicator.textContent = colorMessage;
- indicator.style.cssText = 'margin-top: 8px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 6px; font-weight: bold;';
- statusDiv.appendChild(indicator);
- }
- }
- }
- }
-
- if (state.stopFlag) {
- if (pixelBatch && pixelBatch.pixels.length > 0) {
- console.log(`šÆ Sending last batch before stop with ${pixelBatch.pixels.length} pixels`);
- await flushPixelBatch(pixelBatch);
- }
- state.lastPosition = { x, y };
- // Show paused coordinates in UI with proper translation template
- // Use last painted position if available, otherwise use current position
- const pausedX = state.lastPaintedPosition.x || x;
- const pausedY = state.lastPaintedPosition.y || y;
- updateUI('paintingPaused', 'warning', { x: pausedX, y: pausedY });
- console.log(`āøļø Painting paused after last painted coordinates (${pausedX}, ${pausedY})`);
- return 'stopped';
- }
-
- // Check if we have charges left (local count, no API call)
- if (state.displayCharges <= 0) {
- // console.log("Try to buy paint charges");
- // await purchase("paint_charges");
- await updateStats();
- if (state.displayCharges <= 0) {
- console.log('ā” No charges left (local count), ending painting session');
- if (pixelBatch && pixelBatch.pixels.length > 0) {
- console.log(`šÆ Sending final batch with ${pixelBatch.pixels.length} pixels`);
- await flushPixelBatch(pixelBatch);
- }
- state.lastPosition = { x, y };
- return 'charges_depleted';
- }
- // else {
- // console.log(`š Charges after purchase: ${state.displayCharges}, continuing painting`);
- // }
- }
-
- let absX = startX + x;
- let absY = startY + y;
-
- let adderX = Math.floor(absX / 1000);
- let adderY = Math.floor(absY / 1000);
- let pixelX = absX % 1000;
- let pixelY = absY % 1000;
-
- // CRITICAL FIX: Always check if pixel is already painted (both locally and on canvas)
- if (Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
- console.log(`āļø Skipping already painted pixel at (${x}, ${y}) - marked in local map`);
- continue; // Skip already painted pixels
- }
-
- // REAL-TIME CANVAS CHECK: Verify against actual canvas state to prevent overpainting
- try {
- const existingColorRGBA = await overlayManager.getTilePixelColor(
- regionX + adderX,
- regionY + adderY,
- pixelX,
- pixelY
- ).catch(() => null);
-
- if (existingColorRGBA && Array.isArray(existingColorRGBA)) {
- const [er, eg, eb] = existingColorRGBA;
- const existingMappedColor = Utils.resolveColor(
- [er, eg, eb],
- state.availableColors,
- !state.paintUnavailablePixels
- );
- const isAlreadyCorrect = existingMappedColor.id === targetPixelInfo.mappedColorId;
-
- if (isAlreadyCorrect) {
- console.log(`ā
Pixel at (${x}, ${y}) already has correct color (${existingMappedColor.id}) - marking as painted`);
- // Mark it as painted in local map but DO NOT increment progress counter
- // Progress should only reflect actual painting sequence position
- Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
- continue; // Skip painting this pixel
- }
- }
- } catch (e) {
- // If we can't check the canvas, proceed with painting (better to attempt than skip)
- console.warn(`ā ļø Could not verify canvas state for pixel (${x}, ${y}), proceeding with paint:`, e.message);
- }
-
- const targetMappedColorId = targetPixelInfo.mappedColorId;
-
- // Set up pixel batch for new region if needed
- if (
- !pixelBatch ||
- pixelBatch.regionX !== regionX + adderX ||
- pixelBatch.regionY !== regionY + adderY
- ) {
- if (pixelBatch && pixelBatch.pixels.length > 0) {
- console.log(`š Sending region-change batch with ${pixelBatch.pixels.length} pixels`);
- const success = await flushPixelBatch(pixelBatch);
- if (!success) {
- console.error(`ā Batch failed permanently after retries. Stopping painting.`);
- state.stopFlag = true;
- return 'stopped';
- }
- await updateStats();
- }
-
- pixelBatch = {
- regionX: regionX + adderX,
- regionY: regionY + adderY,
- pixels: [],
- };
- }
-
- // Add pixel to batch (no need to check again - already pre-filtered)
- pixelBatch.pixels.push({
- x: pixelX,
- y: pixelY,
- color: targetMappedColorId,
- localX: x,
- localY: y,
- });
-
- // Send batch if it's full
- const maxBatchSize = calculateBatchSize();
- if (pixelBatch.pixels.length >= maxBatchSize) {
- console.log(`š¦ Sending batch with ${pixelBatch.pixels.length} pixels`);
- const success = await flushPixelBatch(pixelBatch);
- if (!success) {
- console.error(`ā Batch failed permanently after retries. Stopping painting.`);
- state.stopFlag = true;
- return 'stopped';
- }
- pixelBatch.pixels = [];
- await updateStats();
- }
- }
-
- // Send final batch if any pixels remain
- if (pixelBatch && pixelBatch.pixels.length > 0 && !state.stopFlag) {
- console.log(`š Sending final batch with ${pixelBatch.pixels.length} pixels`);
- const success = await flushPixelBatch(pixelBatch);
- if (!success) {
- console.warn(`ā ļø Final batch failed with ${pixelBatch.pixels.length} pixels`);
- }
- }
-
- // If we completed the entire coordinate loop, image is complete
- return state.stopFlag ? 'stopped' : 'completed';
-
- } finally {
- // Log skip statistics for this session
- console.log(`š Session Statistics:`);
- console.log(` New pixels painted: ${state.paintedPixels - (skippedPixels.alreadyPainted || 0)}`);
- console.log(` Already painted detected: ${skippedPixels.alreadyPainted}`);
- console.log(` Total progress: ${state.paintedPixels}`);
- console.log(` Pre-filtered - Transparent: ${skippedPixels.transparent}`);
- console.log(` Pre-filtered - White: ${skippedPixels.white}`);
- console.log(` Pre-filtered - Color Unavailable: ${skippedPixels.colorUnavailable}`);
- }
- }
-
- // Phase 2: Execute cooldown period - wait for target charges (NO token regeneration)
- async function executeCooldownPeriod() {
- console.log('ā±ļø Entering cooldown period - waiting for target charges');
- console.log('š« NO token regeneration during cooldown (even if expired/invalid)');
-
- // Check initial charges to calculate wait time
- let chargeCheckCount = 0;
- // const maxChargeChecks = 10; // REMOVED: No limit on API calls during cooldown
-
- while (!state.stopFlag) {
- chargeCheckCount++;
-
- const { charges, cooldown } = await WPlaceService.getCharges();
- state.displayCharges = Math.floor(charges);
- state.preciseCurrentCharges = charges;
- state.cooldown = cooldown;
-
- if (state.displayCharges >= state.cooldownChargeThreshold) {
- console.log(`ā
Cooldown target reached: ${state.displayCharges}/${state.cooldownChargeThreshold}`);
- NotificationManager.maybeNotifyChargesReached(true);
- await updateStats();
- return 'target_reached';
- }
-
- updateUI('noChargesThreshold', 'warning', {
- time: Utils.msToTimeText(state.cooldown),
- threshold: state.cooldownChargeThreshold,
- current: state.displayCharges,
- });
- await updateStats();
-
- // Smart delay calculation to reduce API calls
- const chargesNeeded = state.cooldownChargeThreshold - state.displayCharges;
- const estimatedWaitTime = chargesNeeded * state.cooldown;
-
- // Use longer delays during cooldown to prevent rate limiting
- let delayTime;
- if (chargeCheckCount < 3) {
- // First few checks - shorter delay
- delayTime = Math.max(3000, state.cooldown); // 3 seconds minimum
- } else if (estimatedWaitTime > 60000) {
- // Long wait expected - check every 15 seconds
- delayTime = 10000;
- } else if (estimatedWaitTime > 30000) {
- // Medium wait - check every 15 seconds
- delayTime = 15000;
- } else {
- // Close to target - check every 5 seconds
- delayTime = 5000;
- }
-
- console.log(`ā±ļø Cooldown check ${chargeCheckCount}: ${state.displayCharges}/${state.cooldownChargeThreshold} charges, waiting 10s before next check`);
- await Utils.sleep(10000);
-
- // REMOVED: No limit on charge checks - bot will wait infinitely until charges are available
- // if (chargeCheckCount >= maxChargeChecks) {
- // console.warn('ā ļø Max charge checks reached during cooldown, continuing anyway');
- // break;
- // }
- }
-
- return 'stopped';
- }
-
- // Phase 3: Regenerate token for new painting session
- async function regenerateTokenForNewSession() {
- console.log('š Regenerating token for new painting session');
-
- try {
- // Force regenerate token for new session
- await ensureToken(true); // forceRefresh = true
-
- if (!getTurnstileToken()) {
- console.error('ā Failed to generate token for new session');
- return false;
- }
-
- console.log('ā
Token regenerated successfully for new session');
- return true;
- } catch (error) {
- console.error('ā Token regeneration failed:', error);
- return false;
- }
- }
-
- // Finalize painting process cleanup
- async function finalizePaintingProcess() {
- console.log('š§¹ Finalizing painting process');
-
- if (window._chargesInterval) {
- clearInterval(window._chargesInterval);
- window._chargesInterval = null;
- }
-
- if (state.stopFlag) {
- // Save progress when stopped to preserve painted map
- Utils.saveProgress();
- } else {
- updateUI('paintingComplete', 'success', { count: state.paintedPixels });
- state.lastPosition = { x: 0, y: 0 }; // Only reset when truly complete
- Utils.saveProgress(); // Save final complete state
- overlayManager.clear();
- const toggleOverlayBtn = document.getElementById('toggleOverlayBtn');
- if (toggleOverlayBtn) {
- toggleOverlayBtn.classList.remove('active');
- toggleOverlayBtn.disabled = true;
- }
- }
- }
-
- // Helper function to check pixel eligibility (shared by painting functions)
- function checkPixelEligibility(x, y) {
- const { width, height, pixels } = state.imageData;
- const transparencyThreshold = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
-
- // CRITICAL FIX: Check module availability before processing
- if (!Utils || typeof Utils.isWhitePixel !== 'function') {
- console.error('ā Utils module not available for pixel eligibility check');
- return {
- eligible: false,
- reason: 'moduleUnavailable',
- };
- }
-
- const idx = (y * width + x) * 4;
- const r = pixels[idx],
- g = pixels[idx + 1],
- b = pixels[idx + 2],
- a = pixels[idx + 3];
-
- if (!state.paintTransparentPixels && a < transparencyThreshold)
- return {
- eligible: false,
- reason: 'transparent',
- };
- if (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
- return {
- eligible: false,
- reason: 'white',
- };
-
- let targetRgb = Utils.isWhitePixel(r, g, b)
- ? [255, 255, 255]
- : Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
-
- const mappedTargetColorId = Utils.resolveColor(
- targetRgb,
- state.availableColors,
- !state.paintUnavailablePixels
- );
-
- if (!state.paintUnavailablePixels && !mappedTargetColorId.id) {
- return {
- eligible: false,
- reason: 'colorUnavailable',
- r,
- g,
- b,
- a,
- mappedColorId: mappedTargetColorId.id,
- };
- }
- return { eligible: true, r, g, b, a, mappedColorId: mappedTargetColorId.id };
- }
-
- // Helper function to skip pixel and log the reason (minimized logging)
- function skipPixel(reason, id, rgb, x, y, skippedPixels) {
- // Minimize logging to prevent console flooding - only log non-routine skips
- if (reason !== 'transparent' && reason !== 'alreadyPainted') {
- console.log(`Skipped pixel for ${reason} (id: ${id}, (${rgb.join(', ')})) at (${x}, ${y})`);
- }
- skippedPixels[reason]++;
- }
- function calculateBatchSize() {
- let targetBatchSize;
-
- // If speed control is disabled, use all available charges
- if (!CONFIG.PAINTING_SPEED_ENABLED) {
- targetBatchSize = state.displayCharges;
- console.log(`š Speed control disabled: using all ${targetBatchSize} available charges`);
- return Math.max(1, targetBatchSize);
- }
-
- // CRITICAL FIX: When speed control is ENABLED, use actual available charges as the batch size
- // This ensures bot sends exactly the number of pixels matching available charges
- // Example: 75 charges ā send 75 pixels, 14 charges ā send 14 pixels
- const availableCharges = state.displayCharges;
-
- if (state.batchMode === 'random') {
- // Generate random batch size within the specified range
- const min = Math.max(1, state.randomBatchMin);
- const max = Math.max(min, state.randomBatchMax);
- targetBatchSize = Math.floor(Math.random() * (max - min + 1)) + min;
- console.log(`š² Random batch size generated: ${targetBatchSize} (range: ${min}-${max})`);
- } else {
- // Normal mode - use the fixed paintingSpeed value
- targetBatchSize = state.paintingSpeed;
- }
-
- // FIXED: Use actual available charges as the maximum batch size
- // This matches the available pixels exactly (e.g., 75 charges = 75 pixel batch)
- const finalBatchSize = Math.min(targetBatchSize, availableCharges);
-
- console.log(`š Batch size: ${finalBatchSize} (target: ${targetBatchSize}, available: ${availableCharges})`);
-
- return Math.max(1, finalBatchSize);
- }
-
- // Helper function to retry batch until success with exponential backoff
- async function sendBatchWithRetry(pixels, regionX, regionY, maxRetries = MAX_BATCH_RETRIES) {
- let attempt = 0;
- while (attempt < maxRetries && !state.stopFlag) {
- attempt++;
- console.log(
- `š Attempting to send batch (attempt ${attempt}/${maxRetries}) for region ${regionX},${regionY} with ${pixels.length} pixels`
- );
-
- const result = await sendPixelBatch(pixels, regionX, regionY);
-
- if (result === true) {
- console.log(`ā
Batch succeeded on attempt ${attempt}`);
- return true;
- } else if (result === 'token_error') {
- console.log(`š Token error on attempt ${attempt} - no token available during processing`);
- console.log(`ā Stopping batch processing - tokens must be generated at startup/start button only`);
- updateUI('captchaFailed', 'error');
- await Utils.sleep(2000); // Wait longer before retrying after token failure
- continue; // Continue to retry until maxRetries reached
- } else if (result === 'token_regenerated') {
- console.log(`š Token regenerated on attempt ${attempt} after 403 error - retrying batch`);
- const pausedX = state.lastPaintedPosition.x;
- const pausedY = state.lastPaintedPosition.y;
- updateUI('paintingPaused', 'warning', { x: pausedX, y: pausedY });
- // Don't count token regeneration as a failed attempt, retry immediately
- attempt--;
- await Utils.sleep(500); // Brief pause before retry
- continue;
- } else if (result === 'token_regeneration_failed') {
- console.log(`ā Token regeneration failed on attempt ${attempt} after 403 error`);
- updateUI('captchaFailed', 'error');
- return false; // Stop processing if we can't get a valid token
- } else if (result === 'invalid_token_error') {
- console.log(`š Invalid token detected on attempt ${attempt}, regenerating...`);
- updateUI('captchaSolving', 'warning');
- try {
- await handleCaptcha(true); // Allow generation for invalid token cases
- // Don't count token regeneration as a failed attempt
- attempt--;
- continue;
- } catch (e) {
- console.error(`ā Token regeneration failed after invalid token on attempt ${attempt}:`, e);
- updateUI('captchaFailed', 'error');
- // Wait longer before retrying after token failure
- await Utils.sleep(5000);
- }
- } else {
- console.warn(`ā ļø Batch failed on attempt ${attempt}, retrying...`);
- // Exponential backoff with jitter
- const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 30000); // Max 30s
- const jitter = Math.random() * 1000; // Add up to 1s random delay
- await Utils.sleep(baseDelay + jitter);
- }
- }
-
- if (attempt >= maxRetries) {
- console.error(
- `ā Batch failed after ${maxRetries} attempts (MAX_BATCH_RETRIES=${MAX_BATCH_RETRIES}). This will stop painting to prevent infinite loops.`
- );
- updateUI('paintingError', 'error');
- return false;
- }
-
- return false;
- }
-
- async function sendPixelBatch(pixelBatch, regionX, regionY) {
- let token = getTurnstileToken();
-
- // Don't auto-generate tokens during processing - return error if no token available
- if (!token) {
- console.warn('ā ļø No token available and auto-generation disabled during processing');
- return 'token_error';
- }
-
- const coords = new Array(pixelBatch.length * 2);
- const colors = new Array(pixelBatch.length);
- for (let i = 0; i < pixelBatch.length; i++) {
- const pixel = pixelBatch[i];
- coords[i * 2] = pixel.x;
- coords[i * 2 + 1] = pixel.y;
- colors[i] = pixel.color;
- }
-
- try {
- const payload = { coords, colors, t: token, fp: fpStr32 };
- var wasmtoken = await createWasmToken(regionX, regionY, payload);
- const res = await fetch(`https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`, {
- method: 'POST',
- headers: { 'Content-Type': 'text/plain;charset=UTF-8', "x-pawtect-token": wasmtoken },
- credentials: 'include',
- body: JSON.stringify(payload),
- });
-
- if (res.status === 403) {
- let data = null;
- try {
- data = await res.json();
- } catch (_) { }
- console.error('ā 403 Forbidden. Token invalid during painting - regeneration allowed.');
-
- // 403 errors during painting allow token regeneration per workflow requirements
- console.log('ļæ½ Token invalid (403) during painting - regenerating token as allowed by workflow');
- setTurnstileToken(null);
- createTokenPromise();
-
- // Attempt to regenerate token immediately
- const newToken = await ensureToken(true);
- if (newToken) {
- console.log('ā
Token regenerated after 403 error, returning regenerate signal');
- return 'token_regenerated';
- } else {
- console.error('ā Failed to regenerate token after 403 error');
- return 'token_regeneration_failed';
- }
- }
-
- const data = await res.json();
- return data?.painted === pixelBatch.length;
- } catch (e) {
- console.error('Batch paint request failed:', e);
- return false;
- }
- }
-
- function saveBotSettings() {
- try {
- const settings = {
- paintingSpeed: state.paintingSpeed,
- paintingSpeedEnabled: document.getElementById('enableSpeedToggle')?.checked,
- batchMode: state.batchMode, // "normal" or "random"
- paintingOrder: state.paintingOrder, // "sequential" or "color-by-color"
- randomBatchMin: state.randomBatchMin,
- randomBatchMax: state.randomBatchMax,
- cooldownChargeThreshold: state.cooldownChargeThreshold,
- tokenSource: state.tokenSource, // "generator", "hybrid", or "manual"
- minimized: state.minimized,
- overlayOpacity: state.overlayOpacity,
- blueMarbleEnabled: document.getElementById('enableBlueMarbleToggle')?.checked,
- ditheringEnabled: state.ditheringEnabled,
- colorMatchingAlgorithm: state.colorMatchingAlgorithm,
- enableChromaPenalty: state.enableChromaPenalty,
- chromaPenaltyWeight: state.chromaPenaltyWeight,
- customTransparencyThreshold: state.customTransparencyThreshold,
- customWhiteThreshold: state.customWhiteThreshold,
- paintWhitePixels: state.paintWhitePixels,
- paintTransparentPixels: state.paintTransparentPixels,
- resizeSettings: state.resizeSettings,
- paintUnavailablePixels: state.paintUnavailablePixels,
- coordinateMode: state.coordinateMode,
- coordinateDirection: state.coordinateDirection,
- coordinateSnake: state.coordinateSnake,
- blockWidth: state.blockWidth,
- blockHeight: state.blockHeight, // Save ignore mask (as base64) with its dimensions
- resizeIgnoreMask:
- state.resizeIgnoreMask &&
- state.resizeSettings &&
- state.resizeSettings.width * state.resizeSettings.height === state.resizeIgnoreMask.length
- ? {
- w: state.resizeSettings.width,
- h: state.resizeSettings.height,
- data: btoa(String.fromCharCode(...state.resizeIgnoreMask)),
- }
- : null, // Notifications
- notificationsEnabled: state.notificationsEnabled,
- notifyOnChargesReached: state.notifyOnChargesReached,
- notifyOnlyWhenUnfocused: state.notifyOnlyWhenUnfocused,
- notificationIntervalMinutes: state.notificationIntervalMinutes,
- originalImage: state.originalImage,
- };
- CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled;
- // AUTO_CAPTCHA_ENABLED is always true - no need to save/load
-
- localStorage.setItem('wplace-bot-settings', JSON.stringify(settings));
- } catch (e) {
- console.warn('Could not save bot settings:', e);
- }
- }
-
- function loadBotSettings() {
- try {
- const saved = localStorage.getItem('wplace-bot-settings');
- if (!saved) return;
- const settings = JSON.parse(saved);
-
- state.paintingSpeed = settings.paintingSpeed || CONFIG.PAINTING_SPEED.DEFAULT;
- state.batchMode = settings.batchMode || CONFIG.BATCH_MODE; // Default to "normal"
- state.paintingOrder = settings.paintingOrder || CONFIG.PAINTING_ORDER; // Default to "sequential"
- state.randomBatchMin = settings.randomBatchMin || CONFIG.RANDOM_BATCH_RANGE.MIN;
- state.randomBatchMax = settings.randomBatchMax || CONFIG.RANDOM_BATCH_RANGE.MAX;
- state.cooldownChargeThreshold =
- settings.cooldownChargeThreshold || CONFIG.COOLDOWN_CHARGE_THRESHOLD;
- state.tokenSource = settings.tokenSource || CONFIG.TOKEN_SOURCE; // Default to "generator"
- state.minimized = settings.minimized ?? false;
- CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled ?? false;
- CONFIG.AUTO_CAPTCHA_ENABLED = settings.autoCaptchaEnabled ?? false;
- state.overlayOpacity = settings.overlayOpacity ?? CONFIG.OVERLAY.OPACITY_DEFAULT;
- state.blueMarbleEnabled = settings.blueMarbleEnabled ?? CONFIG.OVERLAY.BLUE_MARBLE_DEFAULT;
- state.ditheringEnabled = settings.ditheringEnabled ?? false;
- state.colorMatchingAlgorithm = settings.colorMatchingAlgorithm || 'lab';
- state.enableChromaPenalty = settings.enableChromaPenalty ?? true;
- state.chromaPenaltyWeight = settings.chromaPenaltyWeight ?? 0.15;
- state.customTransparencyThreshold =
- settings.customTransparencyThreshold ?? CONFIG.TRANSPARENCY_THRESHOLD;
- state.customWhiteThreshold = settings.customWhiteThreshold ?? CONFIG.WHITE_THRESHOLD;
- state.paintWhitePixels = settings.paintWhitePixels ?? true;
- state.paintTransparentPixels = settings.paintTransparentPixels ?? false;
- state.resizeSettings = settings.resizeSettings ?? null;
- state.originalImage = settings.originalImage ?? null;
- state.paintUnavailablePixels = settings.paintUnavailablePixels ?? CONFIG.PAINT_UNAVAILABLE;
- state.coordinateMode = settings.coordinateMode ?? CONFIG.COORDINATE_MODE;
- state.coordinateDirection = settings.coordinateDirection ?? CONFIG.COORDINATE_DIRECTION;
- state.coordinateSnake = settings.coordinateSnake ?? CONFIG.COORDINATE_SNAKE;
- state.blockWidth = settings.blockWidth ?? CONFIG.COORDINATE_BLOCK_WIDTH;
- state.blockHeight = settings.blockHeight ?? CONFIG.COORDINATE_BLOCK_HEIGHT;
- // Notifications
- state.notificationsEnabled = settings.notificationsEnabled ?? CONFIG.NOTIFICATIONS.ENABLED;
- state.notifyOnChargesReached =
- settings.notifyOnChargesReached ?? CONFIG.NOTIFICATIONS.ON_CHARGES_REACHED;
- state.notifyOnlyWhenUnfocused =
- settings.notifyOnlyWhenUnfocused ?? CONFIG.NOTIFICATIONS.ONLY_WHEN_UNFOCUSED;
- state.notificationIntervalMinutes =
- settings.notificationIntervalMinutes ?? CONFIG.NOTIFICATIONS.REPEAT_MINUTES;
- // Restore ignore mask if dims match current resizeSettings
- if (
- settings.resizeIgnoreMask &&
- settings.resizeIgnoreMask.data &&
- state.resizeSettings &&
- settings.resizeIgnoreMask.w === state.resizeSettings.width &&
- settings.resizeIgnoreMask.h === state.resizeSettings.height
- ) {
- try {
- const bin = atob(settings.resizeIgnoreMask.data);
- const arr = new Uint8Array(bin.length);
- for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
- state.resizeIgnoreMask = arr;
- } catch {
- state.resizeIgnoreMask = null;
- }
- } else {
- state.resizeIgnoreMask = null;
- }
- // Initialize coordinate generation UI
- const coordinateModeSelect = document.getElementById('coordinateModeSelect');
- if (coordinateModeSelect) coordinateModeSelect.value = state.coordinateMode;
-
- const coordinateDirectionSelect = document.getElementById('coordinateDirectionSelect');
- if (coordinateDirectionSelect) coordinateDirectionSelect.value = state.coordinateDirection;
-
- const coordinateSnakeToggle = document.getElementById('coordinateSnakeToggle');
- if (coordinateSnakeToggle) coordinateSnakeToggle.checked = state.coordinateSnake;
-
- const settingsContainer = document.getElementById('wplace-settings-container');
- const directionControls = settingsContainer.querySelector('#directionControls');
- const snakeControls = settingsContainer.querySelector('#snakeControls');
- const blockControls = settingsContainer.querySelector('#blockControls');
- Utils.updateCoordinateUI({
- mode: state.coordinateMode,
- directionControls,
- snakeControls,
- blockControls,
- });
-
- const paintUnavailablePixelsToggle = document.getElementById('paintUnavailablePixelsToggle');
- if (paintUnavailablePixelsToggle) {
- paintUnavailablePixelsToggle.checked = state.paintUnavailablePixels;
- }
-
- const settingsPaintWhiteToggle = settingsContainer.querySelector('#settingsPaintWhiteToggle');
- if (settingsPaintWhiteToggle) {
- settingsPaintWhiteToggle.checked = state.paintWhitePixels;
- }
-
- const settingsPaintTransparentToggle = settingsContainer.querySelector(
- '#settingsPaintTransparentToggle'
- );
- if (settingsPaintTransparentToggle) {
- settingsPaintTransparentToggle.checked = state.paintTransparentPixels;
- }
-
- const speedSlider = document.getElementById('speedSlider');
- const speedInput = document.getElementById('speedInput');
- const speedValue = document.getElementById('speedValue');
- if (speedSlider) speedSlider.value = state.paintingSpeed;
- if (speedInput) speedInput.value = state.paintingSpeed;
- if (speedValue) speedValue.textContent = `pixels`;
-
- const enableSpeedToggle = document.getElementById('enableSpeedToggle');
- if (enableSpeedToggle) enableSpeedToggle.checked = CONFIG.PAINTING_SPEED_ENABLED;
-
- // Painting order UI initialization
- const paintingOrderSelect = document.getElementById('paintingOrderSelect');
- if (paintingOrderSelect) paintingOrderSelect.value = state.paintingOrder;
-
- // Batch mode UI initialization
- const batchModeSelect = document.getElementById('batchModeSelect');
- if (batchModeSelect) batchModeSelect.value = state.batchMode;
-
- const normalBatchControls = document.getElementById('normalBatchControls');
- const randomBatchControls = document.getElementById('randomBatchControls');
-
- // Show/hide appropriate controls based on batch mode
- if (normalBatchControls && randomBatchControls) {
- if (state.batchMode === 'random') {
- normalBatchControls.style.display = 'none';
- randomBatchControls.style.display = 'block';
- } else {
- normalBatchControls.style.display = 'block';
- randomBatchControls.style.display = 'none';
- }
- }
-
- const randomBatchMin = document.getElementById('randomBatchMin');
- if (randomBatchMin) randomBatchMin.value = state.randomBatchMin;
-
- const randomBatchMax = document.getElementById('randomBatchMax');
- if (randomBatchMax) randomBatchMax.value = state.randomBatchMax;
-
- // AUTO_CAPTCHA_ENABLED is always true - no toggle to set
-
- const cooldownSlider = document.getElementById('cooldownSlider');
- const cooldownInput = document.getElementById('cooldownInput');
- const cooldownValue = document.getElementById('cooldownValue');
- if (cooldownSlider) cooldownSlider.value = state.cooldownChargeThreshold;
- if (cooldownInput) cooldownInput.value = state.cooldownChargeThreshold;
- if (cooldownValue) cooldownValue.textContent = `${Utils.t('charges')}`;
-
- const overlayOpacitySlider = document.getElementById('overlayOpacitySlider');
- if (overlayOpacitySlider) overlayOpacitySlider.value = state.overlayOpacity;
- const overlayOpacityValue = document.getElementById('overlayOpacityValue');
- if (overlayOpacityValue)
- overlayOpacityValue.textContent = `${Math.round(state.overlayOpacity * 100)}%`;
- const enableBlueMarbleToggle = document.getElementById('enableBlueMarbleToggle');
- if (enableBlueMarbleToggle) enableBlueMarbleToggle.checked = state.blueMarbleEnabled;
-
- const tokenSourceSelect = document.getElementById('tokenSourceSelect');
- if (tokenSourceSelect) tokenSourceSelect.value = state.tokenSource;
-
- const colorAlgorithmSelect = document.getElementById('colorAlgorithmSelect');
- if (colorAlgorithmSelect) colorAlgorithmSelect.value = state.colorMatchingAlgorithm;
- const enableChromaPenaltyToggle = document.getElementById('enableChromaPenaltyToggle');
- if (enableChromaPenaltyToggle) enableChromaPenaltyToggle.checked = state.enableChromaPenalty;
- const chromaPenaltyWeightSlider = document.getElementById('chromaPenaltyWeightSlider');
- if (chromaPenaltyWeightSlider) chromaPenaltyWeightSlider.value = state.chromaPenaltyWeight;
- const chromaWeightValue = document.getElementById('chromaWeightValue');
- if (chromaWeightValue) chromaWeightValue.textContent = state.chromaPenaltyWeight;
- const transparencyThresholdInput = document.getElementById('transparencyThresholdInput');
- if (transparencyThresholdInput)
- transparencyThresholdInput.value = state.customTransparencyThreshold;
- const whiteThresholdInput = document.getElementById('whiteThresholdInput');
- if (whiteThresholdInput) whiteThresholdInput.value = state.customWhiteThreshold;
- // Notifications UI
- const notifEnabledToggle = document.getElementById('notifEnabledToggle');
- if (notifEnabledToggle) notifEnabledToggle.checked = state.notificationsEnabled;
- const notifOnChargesToggle = document.getElementById('notifOnChargesToggle');
- if (notifOnChargesToggle) notifOnChargesToggle.checked = state.notifyOnChargesReached;
- const notifOnlyUnfocusedToggle = document.getElementById('notifOnlyUnfocusedToggle');
- if (notifOnlyUnfocusedToggle)
- notifOnlyUnfocusedToggle.checked = state.notifyOnlyWhenUnfocused;
- const notifIntervalInput = document.getElementById('notifIntervalInput');
- if (notifIntervalInput) notifIntervalInput.value = state.notificationIntervalMinutes;
- NotificationManager.resetEdgeTracking();
- } catch (e) {
- console.warn('Could not load bot settings:', e);
- }
- }
-
- // Initialize Turnstile generator integration
- console.log('š WPlace Auto-Image with Turnstile Token Generator loaded');
- console.log('š Turnstile token generator: ALWAYS ENABLED (Background mode)');
- console.log('šÆ Manual pixel captcha solving: Available as fallback/alternative');
- console.log('š± Turnstile widgets: DISABLED - pure background token generation only!');
-
- // Generate fingerprint string for requests
- if (!window.WPlaceTokenManager) {
- console.error('ā WPlaceTokenManager not available - dependency loading issue');
- return;
- }
- const fpStr32 = tokenManager._randStr(32);
- console.log('š Generated fingerprint string for API requests');
-
- // Function to enable file operations after initial startup setup is complete
- function enableFileOperations() {
- state.initialSetupComplete = true;
-
- const loadBtn = document.querySelector('#loadBtn');
- const loadFromFileBtn = document.querySelector('#loadFromFileBtn');
- const uploadBtn = document.querySelector('#uploadBtn');
-
- if (loadBtn) {
- loadBtn.disabled = false;
- loadBtn.title = '';
- // Add a subtle animation to indicate the button is now available
- loadBtn.style.animation = 'pulse 0.6s ease-in-out';
- setTimeout(() => {
- if (loadBtn) loadBtn.style.animation = '';
- }, 600);
- console.log('ā
Load Progress button enabled after initial setup');
- }
-
- if (loadFromFileBtn) {
- loadFromFileBtn.disabled = false;
- loadFromFileBtn.title = '';
- // Add a subtle animation to indicate the button is now available
- loadFromFileBtn.style.animation = 'pulse 0.6s ease-in-out';
- setTimeout(() => {
- if (loadFromFileBtn) loadFromFileBtn.style.animation = '';
- }, 600);
- console.log('ā
Load from File button enabled after initial setup');
- }
-
- if (uploadBtn) {
- uploadBtn.disabled = false;
- uploadBtn.title = '';
- // Add a subtle animation to indicate the button is now available
- uploadBtn.style.animation = 'pulse 0.6s ease-in-out';
- setTimeout(() => {
- if (uploadBtn) uploadBtn.style.animation = '';
- }, 600);
- console.log('ā
Upload Image button enabled after initial setup');
- }
-
- // Enable Load Extracted button at the same time as Upload button
- const loadExtractedBtn = document.getElementById('loadExtractedBtn');
- if (loadExtractedBtn) {
- loadExtractedBtn.disabled = false;
- loadExtractedBtn.title = '';
- console.log('ā
Load Extracted button enabled after initial setup');
- }
-
- // Show a notification that file operations are now available
- Utils.showAlert(Utils.t('fileOperationsAvailable'), 'success');
- }
-
- // Optimized token initialization with better timing and error handling
- async function initializeTokenGenerator() {
- // Skip if already have valid token
- if (isTokenValid()) {
- console.log('ā
Valid token already available, skipping initialization');
- updateUI('tokenReady', 'success');
- enableFileOperations(); // Enable file operations since initial setup is complete
- return;
- }
-
- try {
- console.log('š§ Initializing Turnstile token generator...');
- updateUI('initializingToken', 'default');
-
- console.log('Attempting to load Turnstile script...');
- await Utils.loadTurnstile();
- console.log('Turnstile script loaded. Attempting to generate token...');
-
- // Use TokenManager's handleCaptchaWithRetry method instead
- const token = await tokenManager.handleCaptchaWithRetry();
- if (token) {
- setTurnstileToken(token);
- console.log('ā
Startup token generated successfully');
- updateUI('tokenReady', 'success');
- Utils.showAlert(Utils.t('tokenGeneratorReady'), 'success');
- enableFileOperations(); // Enable file operations since initial setup is complete
- } else {
- console.warn(
- 'ā ļø Startup token generation failed (no token received), will retry when needed'
- );
- updateUI('tokenRetryLater', 'warning');
- // Still enable file operations even if initial token generation fails
- // Users can load progress and use manual/hybrid modes
- enableFileOperations();
- }
- } catch (error) {
- console.error('ā Critical error during Turnstile initialization:', error); // More specific error
- updateUI('tokenRetryLater', 'warning');
- // Still enable file operations even if initial setup fails
- // Users can load progress and use manual/hybrid modes
- enableFileOperations();
- // Don't show error alert for initialization failures, just log them
- }
- }
-
- // Load theme preference immediately on startup before creating UI
- loadThemePreference();
- applyTheme();
-
- var pawtect_chunk = null;
-
- //find module if pawtect_chunk is null
- pawtect_chunk ??= await findTokenModule("pawtect_wasm_bg.wasm");
-
- async function createWasmToken(regionX, regionY, payload) {
- try {
- // Load the Pawtect module and WASM
- const mod = await import(new URL('/_app/immutable/chunks/' + pawtect_chunk, location.origin).href);
- let wasm;
- try {
- wasm = await mod._();
- console.log('ā
WASM initialized successfully');
- } catch (wasmError) {
- console.error('ā WASM initialization failed:', wasmError);
- return null;
- }
- try {
- try {
- const me = await fetch(`https://backend.wplace.live/me`, { credentials: 'include' }).then(r => r.ok ? r.json() : null);
- if (me?.id) {
- mod.i(me.id);
- console.log('ā
user ID set:', me.id);
- }
- } catch { }
- } catch (userIdError) {
- console.log('ā ļø Error setting user ID:', userIdError.message);
- }
- try {
- const testUrl = `https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`;
- if (mod.r) {
- mod.r(testUrl);
- console.log('ā
Request URL set:', testUrl);
- } else {
- console.log('ā ļø request_url function (mod.r) not available');
- }
- } catch (urlError) {
- console.log('ā ļø Error setting request URL:', urlError.message);
- }
-
- // Create test payload
-
- console.log('š payload:', payload);
-
- // Encode payload
- const enc = new TextEncoder();
- const dec = new TextDecoder();
- const bodyStr = JSON.stringify(payload);
- const bytes = enc.encode(bodyStr);
- console.log('š Payload size:', bytes.length, 'bytes');
- console.log('š Payload string:', bodyStr);
-
- // Allocate WASM memory with validation
- let inPtr;
- try {
- if (!wasm.__wbindgen_malloc) {
- console.error('ā __wbindgen_malloc function not found');
- return null;
- }
-
- inPtr = wasm.__wbindgen_malloc(bytes.length, 1);
- console.log('ā
WASM memory allocated, pointer:', inPtr);
-
- // Copy data to WASM memory
- const wasmBuffer = new Uint8Array(wasm.memory.buffer, inPtr, bytes.length);
- wasmBuffer.set(bytes);
- console.log('ā
Data copied to WASM memory');
- } catch (memError) {
- console.error('ā Memory allocation error:', memError);
- return null;
- }
-
- // Call the WASM function
- console.log('š Calling get_pawtected_endpoint_payload...');
- let outPtr, outLen, token;
- try {
- const result = wasm.get_pawtected_endpoint_payload(inPtr, bytes.length);
- console.log('ā
Function called, result type:', typeof result, result);
-
- if (Array.isArray(result) && result.length === 2) {
- [outPtr, outLen] = result;
- console.log('ā
Got output pointer:', outPtr, 'length:', outLen);
-
- // Decode the result
- const outputBuffer = new Uint8Array(wasm.memory.buffer, outPtr, outLen);
- token = dec.decode(outputBuffer);
- console.log('ā
Token decoded successfully');
- } else {
- console.error('ā Unexpected function result format:', result);
- return null;
- }
- } catch (funcError) {
- console.error('ā Function call error:', funcError);
- console.error('Stack trace:', funcError.stack);
- return null;
- }
-
- // Cleanup memory
- try {
- if (wasm.__wbindgen_free && outPtr && outLen) {
- wasm.__wbindgen_free(outPtr, outLen, 1);
- console.log('ā
Output memory freed');
- }
- if (wasm.__wbindgen_free && inPtr) {
- wasm.__wbindgen_free(inPtr, bytes.length, 1);
- console.log('ā
Input memory freed');
- }
- } catch (cleanupError) {
- console.log('ā ļø Cleanup warning:', cleanupError.message);
- }
-
- // Display results
- console.log('');
- console.log('š SUCCESS!');
- console.log('š Full token:');
- console.log(token);
- return token;
- } catch (error) {
- console.error('ā Failed to generate fp parameter:', error);
- return null;
- }
- }
-
- async function findTokenModule(str) {
- console.log('š Searching for wasm Module...');
- const links = Array.from(document.querySelectorAll('link[rel="modulepreload"][href$=".js"]'));
-
- for (const link of links) {
- try {
- const url = new URL(link.getAttribute("href"), location.origin).href;
- const code = await fetch(url).then(r => r.text());
- if (code.includes(str)) {
- console.log('ā
Found wasm Module...');
- return url.split('/').pop();
- }
- } catch { }
- }
- console.error(`ā Could not find Pawtect chunk: `, error);
- }
-
- async function purchase(type) {
- // loadThemePreference()
- let id;
- let chargeMultiplier;
- if (type === "max_charges") {
- id = 70;
- chargeMultiplier = 5;
- } else if (type === "paint_charges") {
- id = 80;
- chargeMultiplier = 30;
- } else {
- console.error("Error: Invalid purchase type provided.");
- return;
- }
- const { droplets } = await WPlaceService.getCharges();
- console.log("There are currently : ", droplets, "droplets.");
- try {
- const amounts = Math.floor(droplets / 500);
- if (amounts < 1) {
- console.log("Not enough droplets to purchase.");
- return;
- }
- const payload = {
- "product": {
- "id": id,
- "amount": amounts
- }
- };
- const res = await fetch("https://backend.wplace.live/purchase", {
- method: "POST",
- headers: {
- "Content-Type": "text/plain;charset=UTF-8"
- },
- body: JSON.stringify(payload),
- credentials: "include"
- });
- // POST request completed
- const { droplets: newDroplets } = await WPlaceService.getCharges();
- if (droplets != newDroplets) {
- console.log("Successfully bought", amounts * chargeMultiplier, type.replace('_', ' '), ".");
- return 2;
- } else {
- console.log("Failed to buy charges");
- return 1;
- }
- } catch (e) {
- console.error("An error occurred during the purchase:", e);
- return 0;
- }
- }
- async function swapAccountTrigger(token) {
- // STRICT GUARD: Only allow account switching during active painting sessions OR controlled refresh
- if (!state.running && !state.isFetchingAllAccounts) {
- console.warn('š Account switching blocked - only allowed during active painting or controlled refresh');
- console.warn('š Current state.running:', state.running, 'state.isFetchingAllAccounts:', state.isFetchingAllAccounts);
- return false;
- }
-
- localStorage.removeItem("lp");
- if (!token) {
- console.error('ā Cannot swap account: token is null or undefined');
- return false;
- }
-
- console.log(`š Triggering account swap with token: ${token.substring(0, 20)}...`);
- console.log('š¤ Sending setCookie message to extension...');
-
- try {
- window.postMessage({
- source: 'my-userscript',
- type: 'setCookie',
- value: token
- }, '*');
-
- console.log('ā
setCookie message sent successfully');
- return true;
- } catch (error) {
- console.error('ā Failed to send setCookie message:', error);
- return false;
- }
- }
- async function getAccounts() {
- return new Promise((resolve, reject) => {
- console.log("Requesting accounts from extension...");
-
- // Ask extension for accounts
- window.postMessage({
- source: "my-userscript",
- type: "getAccounts"
- }, "*");
-
- function handler(event) {
- if (event.source !== window) return;
- if (event.data.source !== "extension") return;
- if (event.data.type === "accountsData") {
- // Remove listener when we get the response
- window.removeEventListener("message", handler);
-
- try {
- localStorage.setItem("accounts", JSON.stringify(event.data.accounts));
- console.log("ā
Accounts saved to localStorage:", event.data.accounts);
- } catch (e) {
- console.error("ā Failed to save accounts:", e);
- }
- resolve(event.data.accounts);
- }
- }
- window.addEventListener("message", handler);
- });
- }
-
- async function fetchAccount() {
- const { ID, Charges, Max, Droplets } = await WPlaceService.fetchCheck();
- console.log("User's ID :", ID);
- console.log("User's Charges :", Charges, "/", Max);
- console.log("User's Droplets :", Droplets);
- }
-
- async function fetchAllAccountDetails() {
- if (state.isFetchingAllAccounts) {
- Utils.showAlert("Already fetching account details.", "warning");
- return;
- }
-
- state.isFetchingAllAccounts = true;
-
- const refreshBtn = document.getElementById('refreshAllAccountsBtn');
- if (refreshBtn) {
- refreshBtn.innerHTML = '';
- refreshBtn.disabled = true;
- }
-
- const accountsListArea = document.getElementById('accountsListArea');
- if (accountsListArea) {
- accountsListArea.innerHTML = `Loading accounts...
`;
- }
-
- try {
- // First, get accounts from the extension
- console.log(`š [FETCH] Requesting accounts from extension...`);
- try {
- await getAccounts();
- console.log(`ā
[FETCH] Successfully retrieved accounts from extension`);
- } catch (error) {
- console.warn(`ā ļø [FETCH] Failed to get accounts from extension:`, error);
- // Continue anyway in case we have cached accounts
- }
-
- // Load accounts using the new AccountManager
- await accountManager.loadAccounts();
-
- console.log(`ā
[FETCH] Loaded ${accountManager.getAccountCount()} accounts from storage`);
-
- // Debug: Check if we actually have accounts
- if (accountManager.getAccountCount() === 0) {
- console.warn(`ā ļø [FETCH] No accounts found in storage. Check localStorage 'accounts' and chrome.storage 'infoAccounts'`);
-
- // Check localStorage for accounts
- const localStorageAccounts = JSON.parse(localStorage.getItem("accounts")) || [];
- console.log(`š [DEBUG] localStorage accounts:`, localStorageAccounts);
-
- // Render empty state and return early
- renderAccountsList();
- return;
- }
-
- // Now fetch fresh data for each account
- const accounts = accountManager.getAllAccounts();
- if (accounts.length > 0) {
- console.log(`š [FETCH] Fetching fresh data for ${accounts.length} accounts...`);
-
- // Remember the current account so we can switch back
- const originalCurrentAccount = accounts.find(acc => acc.isCurrent);
-
- for (let i = 0; i < accounts.length; i++) {
- const account = accounts[i];
- console.log(`š [FETCH] Fetching data for account ${i + 1}: ${account.displayName}`);
-
- try {
- // Switch to this account temporarily to fetch its data
- console.log(`š [FETCH] Switching to ${account.displayName} to fetch fresh data...`);
- await switchToSpecificAccount(account.token, account.displayName);
- // await Utils.sleep(500); // Small delay to ensure switch takes effect
-
- // Fetch fresh account details
- const accountData = await WPlaceService.getCharges();
- const accountInfo = await WPlaceService.fetchCheck();
-
- // Update account with fresh data
- accountManager.updateAccountData(account.token, {
- ID: accountData.id || accountInfo.ID,
- Charges: Math.floor(accountData.charges || 0),
- Max: Math.floor(accountData.max || 0),
- Droplets: Math.floor(accountData.droplets || 0),
- displayName: accountInfo.Username || accountInfo.name || account.displayName
- });
-
- console.log(`ā
[FETCH] Updated ${account.displayName}: ā”${Math.floor(accountData.charges)}/${Math.floor(accountData.max)} š§${Math.floor(accountData.droplets)}`);
-
- } catch (error) {
- console.warn(`ā ļø [FETCH] Failed to fetch data for ${account.displayName}:`, error);
- }
- }
-
- // Switch back to the original current account if there was one
- if (originalCurrentAccount) {
- console.log(`š [FETCH] Switching back to original current account: ${originalCurrentAccount.displayName}`);
- try {
- await switchToSpecificAccount(originalCurrentAccount.token, originalCurrentAccount.displayName);
- //await Utils.sleep(300);
-
- // Mark it as current again
- accountManager.updateAccountData(originalCurrentAccount.token, {
- isCurrent: true
- });
- } catch (error) {
- console.warn(`ā ļø [FETCH] Failed to switch back to original account:`, error);
- }
- }
-
- console.log(`šÆ [FETCH] Completed fetching fresh data for all accounts`);
- }
-
- // Render the accounts list with fresh data
- renderAccountsList();
-
- } catch (error) {
- console.error('ā [FETCH] Error fetching account details:', error);
- if (accountsListArea) {
- accountsListArea.innerHTML = `Error loading accounts.
`;
- }
- } finally {
- state.isFetchingAllAccounts = false;
- if (refreshBtn) {
- refreshBtn.innerHTML = '';
- refreshBtn.disabled = false;
- }
- }
- }
-
- // Function to update current account charges in the account list
- async function updateCurrentAccountInList() {
- if (accountManager.getAccountCount() === 0) return;
-
- // Find current account in the list and update its charges
- const currentAccount = accountManager.getCurrentAccount();
- if (currentAccount) {
- const { charges, cooldown, droplets } = await WPlaceService.getCharges();
- state.displayCharges = Math.floor(charges);
- state.preciseCurrentCharges = charges;
- await updateStats();
-
- // Update the current account data in AccountManager
- accountManager.updateAccountData(currentAccount.token, {
- Charges: Math.floor(state.displayCharges || state.preciseCurrentCharges || 0),
- Max: state.maxCharges,
- Droplets: Math.floor(droplets)
- });
-
- // Re-render the account list to show updated charges
- renderAccountsList();
- }
- }
-
- // Function to update current account spotlight when switching during painting
- async function updateCurrentAccountSpotlight() {
- if (accountManager.getAccountCount() === 0) return;
- // await Utils.sleep(500); // Wait a bit for the switch to take effect
- try {
- const currentAccountData = await WPlaceService.getCharges();
- console.log("Current account after switch:", currentAccountData);
- console.log(`š Switched to account with ID: ${currentAccountData.id}`);
-
- // Find the current account in AccountManager and update it
- const accounts = accountManager.getAllAccounts();
- const currentAccount = accounts.find(acc => acc.ID === currentAccountData.id);
-
- if (currentAccount) {
- const currentAccountInfo = await WPlaceService.fetchCheck();
-
- // Update account data in AccountManager
- accountManager.updateAccountData(currentAccount.token, {
- isCurrent: true,
- Charges: Math.floor(currentAccountData.charges),
- Max: Math.floor(currentAccountData.max),
- Droplets: Math.floor(currentAccountData.droplets),
- displayName: currentAccountInfo.Username || currentAccountInfo.name || currentAccount.displayName
- });
-
- console.log(`šÆ Updated current account spotlight: ${currentAccount.displayName}`);
-
- // Check for autobuy after account is loaded
- if (CONFIG.autoBuyToggle && Math.floor(currentAccountData.droplets) > 500) {
- console.log(`š° Account has ${Math.floor(currentAccountData.droplets)} droplets (>500), triggering autobuy...`);
- try {
- const purchaseResult = await purchase(CONFIG.autoBuy);
- if (purchaseResult == 2) {
- console.log('ā
Autobuy successful after account switch');
- // Update charges after purchase
- const updatedCharges = await WPlaceService.getCharges();
- accountManager.updateAccountData(currentAccount.token, {
- Charges: Math.floor(updatedCharges.charges),
- Droplets: Math.floor(updatedCharges.droplets)
- });
- // Re-render account list to show updated status after purchase
- renderAccountsList();
- } else {
- console.log('ā Autobuy failed after account switch');
- }
- } catch (error) {
- console.error('ā Error during autobuy after account switch:', error);
- }
- } else if (CONFIG.autoBuyToggle) {
- console.log(`š° Account has ${Math.floor(currentAccountData.droplets)} droplets (ā¤500), skipping autobuy`);
- }
-
- // Re-render the account list to show new current account
- renderAccountsList();
-
- console.log(`š PRESERVING currentActiveIndex: ${state.currentActiveIndex} (do not recalculate from isCurrent flag)`);
- console.log(`ļæ½ Account at currentActiveIndex ${state.currentActiveIndex}: ${state.originalAccountOrder[state.currentActiveIndex]?.displayName} (ID ${state.originalAccountOrder[state.currentActiveIndex]?.orderId})`);
-
- // Update accountIndex to match original array position
- const originalArrayIndex = state.allAccountsInfo.findIndex(acc => acc.isCurrent);
- if (originalArrayIndex !== -1) {
- state.accountIndex = originalArrayIndex;
- }
-
- console.log(`š Final state: activeIndex=${state.currentActiveIndex}, accountIndex=${state.accountIndex}, orderId=${newCurrentAccount.orderId}`);
-
- console.warn(`ā ļø Could not find account ID ${newCurrentAccount.orderId} in switching order`);
-
- console.warn(`ā ļø Could not find switched account with ID ${currentAccountData.id} in account list`);
- }
-
- // Re-render the account list to show new current account
- renderAccountsList();
-
- } catch (error) {
- console.warn('ā ļø Failed to update account spotlight:', error);
- }
- }
-
- function renderAccountsList() {
- const accountsListArea = document.getElementById('accountsListArea');
- if (!accountsListArea) return;
-
- accountsListArea.innerHTML = '';
-
- const accounts = accountManager.getAllAccounts();
- console.log(`š [RENDER] Rendering ${accounts.length} accounts`);
-
- // Debug: Log account data to see what we're working with
- accounts.forEach((account, index) => {
- console.log(`š [RENDER] Account ${index + 1}: ${account.displayName} - ā”${account.Charges}/${account.Max} š§${account.Droplets} ${account.isCurrent ? '(CURRENT)' : ''}`);
- });
-
- if (accounts.length === 0) {
- // Don't show any placeholder - just leave empty
- console.log(`š [RENDER] No accounts to display`);
- return;
- }
-
- accounts.forEach((account, index) => {
- const item = createAccountItem(account, index);
- accountsListArea.appendChild(item);
- });
-
- console.log(`ā
[RENDER] Successfully rendered ${accounts.length} accounts`);
- }
-
- function createAccountItem(account, index) {
- const item = document.createElement('div');
-
- // Determine if this is the current account
- const isCurrentAccount = account.isCurrent;
- const isNextInSequence = accountManager.currentIndex !== -1 &&
- ((accountManager.currentIndex + 1) % accountManager.getAccountCount()) === index;
-
- let itemClasses = 'wplace-account-item';
- if (isCurrentAccount) {
- itemClasses += ' current';
- } else if (isNextInSequence) {
- itemClasses += ' next-in-sequence';
- }
-
- item.className = itemClasses;
-
- // Create ordering number element with 1-based index for user display
- const orderNumber = document.createElement('div');
- orderNumber.className = 'wplace-account-number';
- orderNumber.textContent = index + 1; // Show 1-based index for user-friendly display
-
- // Add visual indicator for current position in sequence
- if (isCurrentAccount) {
- orderNumber.style.background = '#2ecc71'; // Green color
- orderNumber.style.color = 'white';
- orderNumber.style.boxShadow = '0 0 10px rgba(46, 204, 113, 0.8)';
- } else if (isNextInSequence) {
- orderNumber.style.background = '#f39c12'; // Orange color for next
- orderNumber.style.color = 'white';
- orderNumber.style.boxShadow = '0 0 8px rgba(243, 156, 18, 0.6)';
- }
-
- // Create account details
- const details = document.createElement('div');
- details.className = 'wplace-account-details';
-
- const accountName = document.createElement('div');
- accountName.className = 'wplace-account-name';
- const displayName = account.displayName || account.name || `Account ${index + 1}`;
- accountName.textContent = displayName;
- accountName.title = displayName;
-
- const accountStats = document.createElement('div');
- accountStats.className = 'wplace-account-stats';
-
- // Safely handle undefined values
- const charges = account.Charges !== undefined ? Math.floor(account.Charges) : 0;
- const max = account.Max !== undefined ? Math.floor(account.Max) : 0;
- const droplets = account.Droplets !== undefined ? Math.floor(account.Droplets) : 0;
-
- accountStats.innerHTML = `
- ${charges}/${max}
- ${droplets}
- `;
-
- details.appendChild(accountName);
- details.appendChild(accountStats);
-
- // Add status indicators
- const status = document.createElement('div');
- status.className = 'wplace-account-status';
- if (isCurrentAccount) {
- status.innerHTML = '';
- } else if (isNextInSequence) {
- status.innerHTML = '';
- }
-
- item.appendChild(orderNumber);
- item.appendChild(details);
- item.appendChild(status);
-
- return item;
- }
-
- // SIMPLIFIED ACCOUNT SWITCHING
- async function switchToNextAccount(accounts) {
- console.log(`š [SWITCH] Starting account switch`);
-
- // Validate we have accounts
- if (accountManager.getAccountCount() === 0) {
- console.error('ā No accounts available for switching');
- return false;
- }
-
- // Get next account using simplified manager
- const nextAccount = accountManager.switchToNext();
- if (!nextAccount) {
- console.error('ā Failed to get next account');
- return false;
- }
-
- console.log(`šÆ [SWITCH] Switching to: ${nextAccount.displayName} (index ${accountManager.currentIndex})`);
-
- // Ensure token exists
- if (!nextAccount.token) {
- console.error(`ā Missing token for account: ${nextAccount.displayName}`);
- return false;
- }
-
- console.log(`ļæ½ [SWITCH] Using token: ${nextAccount.token.substring(0, 20)}...`);
-
- // Perform the account switch
- try {
- await swapAccountTrigger(nextAccount.token);
-
- // Update account index for backward compatibility
- state.accountIndex = accountManager.currentIndex;
-
- console.log(`ā
[SWITCH] Successfully switched to ${nextAccount.displayName}`);
-
- // Wait a moment for the switch to fully complete
- // await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Update the account status and UI after successful switch
- await updateCurrentAccountInList();
-
- return true;
- } catch (error) {
- console.error('ā [SWITCH] Account switch failed:', error);
- return false;
- }
- }
-
- // SIMPLIFIED helper function for specific account switching
- async function switchToSpecificAccount(token, accountName) {
- console.log(`š [SPECIFIC SWITCH] Attempting to switch to account: ${accountName}`);
- console.log(`š [SPECIFIC SWITCH] Using token: ${token.substring(0, 20)}...`);
-
- await swapAccountTrigger(token);
-
- let maxRetries = 20;
- let retryCount = 0;
- let swapSuccess = false;
-
- while (!swapSuccess && retryCount < maxRetries) {
- console.log(`ā³ [SPECIFIC SWITCH] Waiting for account swap... (Attempt ${retryCount + 1}/${maxRetries})`);
- //await new Promise(resolve => setTimeout(resolve, 1000));
-
- try {
- // await fetchAccount();
- console.log('ā
[SPECIFIC SWITCH] Account swap confirmed.');
- swapSuccess = true;
- } catch (error) {
- console.warn('ā [SPECIFIC SWITCH] Account swap not yet successful. Retrying...', error);
- retryCount++;
-
- if (retryCount % 5 === 0) {
- console.log('š [SPECIFIC SWITCH] Re-triggering account swap...');
- await swapAccountTrigger(token);
- await new Promise(resolve => setTimeout(resolve, 2000));
- }
- }
- }
-
- if (swapSuccess) {
- const { charges, cooldown } = await WPlaceService.getCharges();
- state.displayCharges = Math.floor(charges);
- state.preciseCurrentCharges = charges;
- state.cooldown = cooldown;
- Utils.performSmartSave();
- await updateStats();
-
- // Update account data in manager
- accountManager.updateAccountData({ charges, cooldown });
-
- // Update the account status and UI after successful switch
- await updateCurrentAccountSpotlight();
-
- console.log(`ā
[SPECIFIC SWITCH] Successfully switched to ${accountName} with ${Math.floor(charges)} charges`);
- return true;
- } else {
- console.error(`ā [SPECIFIC SWITCH] Failed to swap to ${accountName} after multiple retries.`);
- return false;
- }
- }
-
- // Wait for dependencies before initializing UI
- async function waitForDependenciesAndInitialize() {
- // Wait for all global managers to be available
- let attempts = 0;
- const maxAttempts = 50; // 5 seconds max wait
-
- while (attempts < maxAttempts) {
- const managers = {
- utilsManager: window.globalUtilsManager,
- imageProcessor: window.globalImageProcessor,
- overlayManager: window.globalOverlayManager,
- tokenManager: window.globalTokenManager
- };
-
- const missingManagers = Object.entries(managers)
- .filter(([name, manager]) => !manager)
- .map(([name]) => name);
-
- if (missingManagers.length === 0) {
- console.log('ā
All global managers are available, initializing UI...');
- break;
- }
-
- console.log(`ā³ Waiting for managers: ${missingManagers.join(', ')} (attempt ${attempts + 1}/${maxAttempts})`);
- await new Promise(resolve => setTimeout(resolve, 100));
- attempts++;
- }
-
- if (attempts >= maxAttempts) {
- console.warn('ā ļø Some global managers not available after waiting, proceeding anyway...');
- console.log('Available managers:', {
- utilsManager: !!window.globalUtilsManager,
- imageProcessor: !!window.globalImageProcessor,
- overlayManager: !!window.globalOverlayManager,
- tokenManager: !!window.globalTokenManager
- });
- }
-
- return createUI();
- }
-
- waitForDependenciesAndInitialize().then(() => {
- // Generate token automatically after UI is ready
- setTimeout(initializeTokenGenerator, 1000);
-
- // Quick initial account load from cache
- setTimeout(async () => {
- console.log('š Initial account load from cache...');
- try {
- await accountManager.loadAccounts();
- if (accountManager.getAccountCount() > 0) {
- console.log(`ā
Loaded ${accountManager.getAccountCount()} cached accounts`);
- renderAccountsList();
- } else {
- console.log('š No cached accounts found');
- }
- } catch (error) {
- console.warn('ā ļø Initial account load failed:', error);
- }
- }, 500);
-
- // Auto-refresh account list on startup (with extension communication)
- setTimeout(() => {
- console.log('š Auto-refreshing account list on startup...');
- fetchAllAccountDetails();
- }, 2000);
-
- // Attach advanced color matching listeners (resize dialog)
- const advancedInit = () => {
- const chromaSlider = document.getElementById('chromaPenaltyWeightSlider');
- const chromaValue = document.getElementById('chromaWeightValue');
- const resetBtn = document.getElementById('resetAdvancedColorBtn');
- const algoSelect = document.getElementById('colorAlgorithmSelect');
- const chromaToggle = document.getElementById('enableChromaPenaltyToggle');
- const transInput = document.getElementById('transparencyThresholdInput');
- const whiteInput = document.getElementById('whiteThresholdInput');
- const ditherToggle = document.getElementById('enableDitheringToggle');
- if (algoSelect)
- algoSelect.addEventListener('change', (e) => {
- state.colorMatchingAlgorithm = e.target.value;
- saveBotSettings();
- _updateResizePreview();
- });
- if (chromaToggle)
- chromaToggle.addEventListener('change', (e) => {
- state.enableChromaPenalty = e.target.checked;
- saveBotSettings();
- _updateResizePreview();
- });
- if (chromaSlider && chromaValue)
- chromaSlider.addEventListener('input', (e) => {
- state.chromaPenaltyWeight = parseFloat(e.target.value) || 0.15;
- chromaValue.textContent = state.chromaPenaltyWeight.toFixed(2);
- saveBotSettings();
- _updateResizePreview();
- });
- if (transInput)
- transInput.addEventListener('change', (e) => {
- const v = parseInt(e.target.value, 10);
- if (!isNaN(v) && v >= 0 && v <= 255) {
- state.customTransparencyThreshold = v;
- CONFIG.TRANSPARENCY_THRESHOLD = v;
- saveBotSettings();
- _updateResizePreview();
- }
- });
- if (whiteInput)
- whiteInput.addEventListener('change', (e) => {
- const v = parseInt(e.target.value, 10);
- if (!isNaN(v) && v >= 200 && v <= 255) {
- state.customWhiteThreshold = v;
- CONFIG.WHITE_THRESHOLD = v;
- saveBotSettings();
- _updateResizePreview();
- }
- });
- if (ditherToggle)
- ditherToggle.addEventListener('change', (e) => {
- state.ditheringEnabled = e.target.checked;
- saveBotSettings();
- _updateResizePreview();
- });
- if (resetBtn)
- resetBtn.addEventListener('click', () => {
- state.colorMatchingAlgorithm = 'lab';
- state.enableChromaPenalty = true;
- state.chromaPenaltyWeight = 0.15;
- state.customTransparencyThreshold = CONFIG.TRANSPARENCY_THRESHOLD = 100;
- state.customWhiteThreshold = CONFIG.WHITE_THRESHOLD = 250;
- saveBotSettings();
- const a = document.getElementById('colorAlgorithmSelect');
- if (a) a.value = 'lab';
- const ct = document.getElementById('enableChromaPenaltyToggle');
- if (ct) ct.checked = true;
- if (chromaSlider) chromaSlider.value = 0.15;
- if (chromaValue) chromaValue.textContent = '0.15';
- if (transInput) transInput.value = 100;
- if (whiteInput) whiteInput.value = 250;
- _updateResizePreview();
- Utils.showAlert(Utils.t('advancedColorSettingsReset'), 'success');
- });
- };
- // Delay to ensure resize UI built
- setTimeout(advancedInit, 500);
-
- // Add cleanup on page unload
- window.addEventListener('beforeunload', () => {
- Utils.cleanupTurnstile();
- });
- });
-})();
\ No newline at end of file
+// ==UserScript==
+// @name WPlaceBot
+// @namespace http://tampermonkey.net/
+// @version 2025-08-08.3
+// @description Bot
+// @author Wbot
+// @match https://wplace.live/*
+// @grant none
+// @icon
+// ==/UserScript==
+localStorage.removeItem("lp");
+
+// Fallback translation function for when utils manager isn't loaded
+function getText(key, params) {
+ // Try to get translation from loadedTranslations
+ try {
+ if (window.loadedTranslations && window.loadedTranslations[key]) {
+ let text = window.loadedTranslations[key];
+ if (params) {
+ Object.keys(params).forEach(paramKey => {
+ text = text.replace(new RegExp(`{{${paramKey}}}`, 'g'), params[paramKey]);
+ });
+ }
+ return text;
+ }
+
+ // Try with state.language if available
+ if (window.state && window.state.language && window.loadedTranslations && window.loadedTranslations[window.state.language] && window.loadedTranslations[window.state.language][key]) {
+ let text = window.loadedTranslations[window.state.language][key];
+ if (params) {
+ Object.keys(params).forEach(paramKey => {
+ text = text.replace(new RegExp(`{{${paramKey}}}`, 'g'), params[paramKey]);
+ });
+ }
+ return text;
+ }
+ } catch (error) {
+ console.warn('Error in getText fallback:', error);
+ }
+
+ return key; // Fallback to key if no translation found
+}
+
+; (async () => {
+ // Prevent multiple instances of this script from running
+ if (window.WPLACE_AUTO_IMAGE_LOADED) {
+ console.log('%cā ļø Auto-Image script already loaded, skipping duplicate execution', 'color: #ff9800; font-weight: bold;');
+ return;
+ }
+ window.WPLACE_AUTO_IMAGE_LOADED = true;
+
+ console.log('%cš WPlace AutoBot Auto-Image Starting...', 'color: #00ff41; font-weight: bold; font-size: 16px;');
+
+ const buyTypes = [
+ 'none',
+ 'max_charges',
+ 'paint_charges'
+ ];
+
+ // CONFIGURATION CONSTANTS
+ const CONFIG = {
+ COOLDOWN_DEFAULT: 31000,
+ TRANSPARENCY_THRESHOLD: 100,
+ WHITE_THRESHOLD: 250,
+ LOG_INTERVAL: 10,
+ PAINTING_SPEED: {
+ MIN: 1, // Minimum 1 pixel batch size
+ MAX: 1000, // Maximum 1000 pixels batch size
+ DEFAULT: 5, // Default 5 pixels batch size
+ },
+ BATCH_MODE: 'normal', // "normal" or "random" - default to normal
+ RANDOM_BATCH_RANGE: {
+ MIN: 3, // Random range minimum
+ MAX: 20, // Random range maximum
+ },
+ PAINTING_ORDER: 'sequential', // "sequential" or "color-by-color" - default to sequential
+ PAINTING_SPEED_ENABLED: false, // Off by default
+ AUTO_CAPTCHA_ENABLED: true, // Turnstile generator enabled by default
+ TOKEN_SOURCE: 'generator', // "generator", "manual", or "hybrid" - default to generator
+ COOLDOWN_CHARGE_THRESHOLD: 1, // Default wait threshold
+ // Desktop Notifications (defaults)
+ NOTIFICATIONS: {
+ ENABLED: false,
+ ON_CHARGES_REACHED: true,
+ ONLY_WHEN_UNFOCUSED: true,
+ REPEAT_MINUTES: 5, // repeat reminder while threshold condition holds
+ },
+ OVERLAY: {
+ OPACITY_DEFAULT: 0.6,
+ BLUE_MARBLE_DEFAULT: false,
+ ditheringEnabled: true,
+ }, // --- START: Color data from colour-converter.js ---
+ // New color structure with proper ID mapping
+ COLOR_MAP: {
+ 0: { id: 1, name: 'Black', rgb: { r: 0, g: 0, b: 0 } },
+ 1: { id: 2, name: 'Dark Gray', rgb: { r: 60, g: 60, b: 60 } },
+ 2: { id: 3, name: 'Gray', rgb: { r: 120, g: 120, b: 120 } },
+ 3: { id: 4, name: 'Light Gray', rgb: { r: 210, g: 210, b: 210 } },
+ 4: { id: 5, name: 'White', rgb: { r: 255, g: 255, b: 255 } },
+ 5: { id: 6, name: 'Deep Red', rgb: { r: 96, g: 0, b: 24 } },
+ 6: { id: 7, name: 'Red', rgb: { r: 237, g: 28, b: 36 } },
+ 7: { id: 8, name: 'Orange', rgb: { r: 255, g: 127, b: 39 } },
+ 8: { id: 9, name: 'Gold', rgb: { r: 246, g: 170, b: 9 } },
+ 9: { id: 10, name: 'Yellow', rgb: { r: 249, g: 221, b: 59 } },
+ 10: { id: 11, name: 'Light Yellow', rgb: { r: 255, g: 250, b: 188 } },
+ 11: { id: 12, name: 'Dark Green', rgb: { r: 14, g: 185, b: 104 } },
+ 12: { id: 13, name: 'Green', rgb: { r: 19, g: 230, b: 123 } },
+ 13: { id: 14, name: 'Light Green', rgb: { r: 135, g: 255, b: 94 } },
+ 14: { id: 15, name: 'Dark Teal', rgb: { r: 12, g: 129, b: 110 } },
+ 15: { id: 16, name: 'Teal', rgb: { r: 16, g: 174, b: 166 } },
+ 16: { id: 17, name: 'Light Teal', rgb: { r: 19, g: 225, b: 190 } },
+ 17: { id: 20, name: 'Cyan', rgb: { r: 96, g: 247, b: 242 } },
+ 18: { id: 44, name: 'Light Cyan', rgb: { r: 187, g: 250, b: 242 } },
+ 19: { id: 18, name: 'Dark Blue', rgb: { r: 40, g: 80, b: 158 } },
+ 20: { id: 19, name: 'Blue', rgb: { r: 64, g: 147, b: 228 } },
+ 21: { id: 21, name: 'Indigo', rgb: { r: 107, g: 80, b: 246 } },
+ 22: { id: 22, name: 'Light Indigo', rgb: { r: 153, g: 177, b: 251 } },
+ 23: { id: 23, name: 'Dark Purple', rgb: { r: 120, g: 12, b: 153 } },
+ 24: { id: 24, name: 'Purple', rgb: { r: 170, g: 56, b: 185 } },
+ 25: { id: 25, name: 'Light Purple', rgb: { r: 224, g: 159, b: 249 } },
+ 26: { id: 26, name: 'Dark Pink', rgb: { r: 203, g: 0, b: 122 } },
+ 27: { id: 27, name: 'Pink', rgb: { r: 236, g: 31, b: 128 } },
+ 28: { id: 28, name: 'Light Pink', rgb: { r: 243, g: 141, b: 169 } },
+ 29: { id: 29, name: 'Dark Brown', rgb: { r: 104, g: 70, b: 52 } },
+ 30: { id: 30, name: 'Brown', rgb: { r: 149, g: 104, b: 42 } },
+ 31: { id: 31, name: 'Beige', rgb: { r: 248, g: 178, b: 119 } },
+ 32: { id: 52, name: 'Light Beige', rgb: { r: 255, g: 197, b: 165 } },
+ 33: { id: 32, name: 'Medium Gray', rgb: { r: 170, g: 170, b: 170 } },
+ 34: { id: 33, name: 'Dark Red', rgb: { r: 165, g: 14, b: 30 } },
+ 35: { id: 34, name: 'Light Red', rgb: { r: 250, g: 128, b: 114 } },
+ 36: { id: 35, name: 'Dark Orange', rgb: { r: 228, g: 92, b: 26 } },
+ 37: { id: 37, name: 'Dark Goldenrod', rgb: { r: 156, g: 132, b: 49 } },
+ 38: { id: 38, name: 'Goldenrod', rgb: { r: 197, g: 173, b: 49 } },
+ 39: { id: 39, name: 'Light Goldenrod', rgb: { r: 232, g: 212, b: 95 } },
+ 40: { id: 40, name: 'Dark Olive', rgb: { r: 74, g: 107, b: 58 } },
+ 41: { id: 41, name: 'Olive', rgb: { r: 90, g: 148, b: 74 } },
+ 42: { id: 42, name: 'Light Olive', rgb: { r: 132, g: 197, b: 115 } },
+ 43: { id: 43, name: 'Dark Cyan', rgb: { r: 15, g: 121, b: 159 } },
+ 44: { id: 45, name: 'Light Blue', rgb: { r: 125, g: 199, b: 255 } },
+ 45: { id: 46, name: 'Dark Indigo', rgb: { r: 77, g: 49, b: 184 } },
+ 46: { id: 47, name: 'Dark Slate Blue', rgb: { r: 74, g: 66, b: 132 } },
+ 47: { id: 48, name: 'Slate Blue', rgb: { r: 122, g: 113, b: 196 } },
+ 48: { id: 49, name: 'Light Slate Blue', rgb: { r: 181, g: 174, b: 241 } },
+ 49: { id: 53, name: 'Dark Peach', rgb: { r: 155, g: 82, b: 73 } },
+ 50: { id: 54, name: 'Peach', rgb: { r: 209, g: 128, b: 120 } },
+ 51: { id: 55, name: 'Light Peach', rgb: { r: 250, g: 182, b: 164 } },
+ 52: { id: 50, name: 'Light Brown', rgb: { r: 219, g: 164, b: 99 } },
+ 53: { id: 56, name: 'Dark Tan', rgb: { r: 123, g: 99, b: 82 } },
+ 54: { id: 57, name: 'Tan', rgb: { r: 156, g: 132, b: 107 } },
+ 55: { id: 36, name: 'Light Tan', rgb: { r: 214, g: 181, b: 148 } },
+ 56: { id: 51, name: 'Dark Beige', rgb: { r: 209, g: 128, b: 81 } },
+ 57: { id: 61, name: 'Dark Stone', rgb: { r: 109, g: 100, b: 63 } },
+ 58: { id: 62, name: 'Stone', rgb: { r: 148, g: 140, b: 107 } },
+ 59: { id: 63, name: 'Light Stone', rgb: { r: 205, g: 197, b: 158 } },
+ 60: { id: 58, name: 'Dark Slate', rgb: { r: 51, g: 57, b: 65 } },
+ 61: { id: 59, name: 'Slate', rgb: { r: 109, g: 117, b: 141 } },
+ 62: { id: 60, name: 'Light Slate', rgb: { r: 179, g: 185, b: 209 } },
+ 63: { id: 0, name: 'Transparent', rgb: null },
+ }, // --- END: Color data ---
+ // Optimized CSS Classes for reuse
+ CSS_CLASSES: {
+ BUTTON_PRIMARY: `
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+ color: white; border: none; border-radius: 8px; padding: 10px 16px;
+ cursor: pointer; font-weight: 500; transition: all 0.3s ease;
+ display: flex; align-items: center; gap: 8px;
+ `,
+ BUTTON_SECONDARY: `
+ background: rgba(255,255,255,0.1); color: white;
+ border: 1px solid rgba(255,255,255,0.2); border-radius: 8px;
+ padding: 8px 12px; cursor: pointer; transition: all 0.3s ease;
+ `,
+ MODERN_CARD: `
+ background: rgba(255,255,255,0.1); border-radius: 12px;
+ padding: 18px; border: 1px solid rgba(255,255,255,0.1);
+ backdrop-filter: blur(5px);
+ `,
+ GRADIENT_TEXT: `
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
+ background-clip: text; font-weight: bold;
+ `,
+ },
+ THEMES: {
+ 'Classic Autobot': {
+ primary: '#000000',
+ secondary: '#111111',
+ accent: '#222222',
+ text: '#ffffff',
+ highlight: '#775ce3',
+ success: '#00ff00',
+ error: '#ff0000',
+ warning: '#ffaa00',
+ fontFamily: "'Segoe UI', Roboto, sans-serif",
+ borderRadius: '12px',
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ boxShadow: '0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1)',
+ backdropFilter: 'blur(10px)',
+ animations: {
+ glow: false,
+ scanline: false,
+ 'pixel-blink': false,
+ },
+ },
+ 'Classic Light': {
+ primary: '#E0E0E1',
+ secondary: '#FBFBFB',
+ accent: '#F3F3F3',
+ text: '#203C5D',
+ highlight: '#203C5D',
+ success: '#28a745',
+ error: '#dc3545',
+ warning: '#ffc107',
+ fontFamily: "'Segoe UI', Roboto, sans-serif",
+ borderRadius: '12px',
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ boxShadow: '0 8px 32px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.08)',
+ backdropFilter: 'blur(10px)',
+ animations: {
+ glow: false,
+ scanline: false,
+ 'pixel-blink': false,
+ },
+ },
+ 'Neon Retro': {
+ primary: '#1a1a2e',
+ secondary: '#16213e',
+ accent: '#0f3460',
+ text: '#00ff41',
+ highlight: '#ff6b35',
+ success: '#39ff14',
+ error: '#ff073a',
+ warning: '#ffff00',
+ neon: '#00ffff',
+ purple: '#bf00ff',
+ pink: '#ff1493',
+ fontFamily: "'Press Start 2P', monospace",
+ borderRadius: '0',
+ borderStyle: 'solid',
+ borderWidth: '3px',
+ boxShadow: '0 0 20px rgba(0, 255, 65, 0.3), inset 0 0 20px rgba(0, 255, 65, 0.1)',
+ backdropFilter: 'none',
+ animations: {
+ glow: true,
+ scanline: true,
+ 'pixel-blink': true,
+ },
+ },
+ 'Neon Retro Cyan': {
+ primary: '#1959A1',
+ secondary: '#3C74AF',
+ accent: '#538CC0',
+ text: '#81DCF7',
+ highlight: '#EA9C00',
+ success: '#39ff14',
+ error: '#ff073a',
+ warning: '#ffff00',
+ neon: '00ffff',
+ purple: '#bf00ff',
+ pink: '#ff1493',
+ fontFamily: "'Press Start 2P', monospace",
+ borderRadius: '0',
+ borderStyle: 'solid',
+ borderWidth: '3px',
+ boxShadow: '0 0 20px rgba(234 156 0, 0.3), inset 0 0 20px rgba(234 156 0, 0.1)',
+ backdropFilter: 'none',
+ animations: {
+ glow: true,
+ scanline: true,
+ 'pixel-blink': true,
+ },
+ },
+ 'Neon Retro Light': {
+ primary: '#E0E0E1',
+ secondary: '#FBFBFB',
+ accent: '#F3F3F3',
+ text: '#203C5D',
+ highlight: '#203C5D',
+ success: '#39ff14',
+ error: '#ff073a',
+ warning: '#ffff00',
+ neon: '#203C5D',
+ purple: '#bf00ff',
+ pink: '#ff1493',
+ fontFamily: "'Press Start 2P', monospace",
+ borderRadius: '0',
+ borderStyle: 'solid',
+ borderWidth: '3px',
+ boxShadow: '0 0 20px rgba(234 156 0, 0.3), inset 0 0 20px rgba(234 156 0, 0.1)',
+ backdropFilter: 'none',
+ animations: {
+ glow: true,
+ scanline: true,
+ 'pixel-blink': true,
+ },
+ },
+ 'Acrylic': {
+ primary: '#00000080',
+ secondary: '#00000040',
+ accent: 'rgba(0,0,0,0.75)',
+ text: '#ffffff',
+ highlight: '#ffffff',
+ success: '#00e500',
+ error: '#e50000',
+ warning: '#e5e500',
+ fontFamily: "'Inter', 'Apple Color Emoji'",
+ borderRadius: '10px',
+ borderStyle: 'solid',
+ borderWidth: '0px',
+ boxShadow: 'none',
+ backdropFilter: 'blur(20px)',
+ animations: {
+ glow: false,
+ scanline: false,
+ 'pixel-blink': false,
+ },
+ },
+ },
+ currentTheme: 'Classic Autobot',
+ PAINT_UNAVAILABLE: true,
+ COORDINATE_MODE: 'rows',
+ COORDINATE_DIRECTION: 'top-left',
+ COORDINATE_SNAKE: true,
+ COORDINATE_BLOCK_WIDTH: 6,
+ COORDINATE_BLOCK_HEIGHT: 2,
+ autoSwap: true,
+ autoBuy: 'none', // "none", "max_charges", or "paint_charges"
+ autoBuyToggle: false,
+ maxChargesStopEnable: false,
+ maxChargesBeforeStop: 1500,
+ };
+
+ // Expose CONFIG globally for the utils manager and other modules
+ window.CONFIG = CONFIG;
+
+ const getCurrentTheme = () => CONFIG.THEMES[CONFIG.currentTheme];
+
+ const switchTheme = (themeName) => {
+ if (CONFIG.THEMES[themeName]) {
+ CONFIG.currentTheme = themeName;
+ saveThemePreference();
+
+ // APPLY THEME VARS/CLASS (new)
+ applyTheme();
+
+ // Recreate UI (kept for now)
+ createUI();
+ }
+ };
+
+ // Add this helper (place it after getCurrentTheme/switchTheme definitions)
+ function applyTheme() {
+ const theme = getCurrentTheme();
+ console.group('%cšØ Applying Theme in Auto-Image Script', 'color: #8b5cf6; font-weight: bold;');
+ console.log(`%cšÆ Target theme: ${CONFIG.currentTheme}`, 'color: #8b5cf6;');
+
+ // Toggle theme class on documentElement so CSS vars cascade to our UI
+ document.documentElement.classList.remove(
+ 'wplace-theme-classic',
+ 'wplace-theme-classic-light',
+ 'wplace-theme-acrylic',
+ 'wplace-theme-neon',
+ 'wplace-theme-neon-cyan',
+ 'wplace-theme-neon-light'
+ );
+
+ let themeClass = 'wplace-theme-classic'; // default
+ let themeFileName = 'classic'; // corresponding file name
+
+ if (CONFIG.currentTheme === 'Neon Retro') {
+ themeClass = 'wplace-theme-neon';
+ themeFileName = 'neon';
+ } else if (CONFIG.currentTheme === 'Classic Light') {
+ themeClass = 'wplace-theme-classic-light';
+ themeFileName = 'classic-light';
+ } else if (CONFIG.currentTheme === 'Neon Retro Cyan') {
+ themeClass = 'wplace-theme-neon-cyan';
+ themeFileName = 'neon-cyan';
+ } else if (CONFIG.currentTheme === 'Neon Retro Light') {
+ themeClass = 'wplace-theme-neon-light';
+ themeFileName = 'neon-light';
+ } else if (CONFIG.currentTheme === 'Acrylic') {
+ themeClass = 'wplace-theme-acrylic';
+ themeFileName = 'acrylic';
+ }
+
+ document.documentElement.classList.add(themeClass);
+ console.log(`%cā
Applied theme class: ${themeClass}`, 'color: #8b5cf6;');
+
+ // Use extension's applyTheme helper if available (loads from local extension resources)
+ if (typeof window.applyTheme === 'function') {
+ console.log(`%cš§ Using extension's applyTheme() helper for: ${themeFileName}`, 'color: #10b981; font-weight: bold;');
+ const success = window.applyTheme(themeFileName);
+ if (success) {
+ console.log(`%cā
Theme CSS loaded from extension local resources`, 'color: #10b981; font-weight: bold;');
+ console.log(` š Source: Extension local file (themes/${themeFileName}.css)`);
+ console.log(` š Performance: Instant load (no network request)`);
+ } else {
+ console.warn(`%cā ļø Extension theme loading failed, fallback to CSS classes only`, 'color: #f59e0b;');
+ console.log(` šØ Using CSS class: ${themeClass}`);
+ console.log(` š Note: Theme variables may be limited without CSS file`);
+ }
+ } else {
+ console.log(`%cš Extension applyTheme() not available, using CSS class only`, 'color: #f59e0b;');
+ console.log(` šØ Using CSS class: ${themeClass}`);
+ console.log(` ā ļø Note: Full theme styling requires extension helper function`);
+ }
+
+ // Also set CSS variables explicitly in case you want runtime overrides
+ const root = document.documentElement;
+ const setVar = (k, v) => {
+ try {
+ root.style.setProperty(k, v);
+ } catch { }
+ };
+
+ setVar('--wplace-primary', theme.primary);
+ setVar('--wplace-secondary', theme.secondary);
+ setVar('--wplace-accent', theme.accent);
+ setVar('--wplace-text', theme.text);
+ setVar('--wplace-highlight', theme.highlight);
+ setVar('--wplace-success', theme.success);
+ setVar('--wplace-error', theme.error);
+ setVar('--wplace-warning', theme.warning);
+
+ // Typography + look
+ setVar('--wplace-font', theme.fontFamily || "'Segoe UI', Roboto, sans-serif");
+ setVar('--wplace-radius', '' + (theme.borderRadius || '12px'));
+ setVar('--wplace-border-style', '' + (theme.borderStyle || 'solid'));
+ setVar('--wplace-border-width', '' + (theme.borderWidth || '1px'));
+ setVar('--wplace-backdrop', '' + (theme.backdropFilter || 'blur(10px)'));
+ setVar('--wplace-border-color', 'rgba(255,255,255,0.1)');
+
+ console.log(`%cšØ Theme application complete`, 'color: #8b5cf6; font-weight: bold;');
+ console.groupEnd();
+ }
+
+ const saveThemePreference = () => {
+ try {
+ localStorage.setItem('wplace-theme', CONFIG.currentTheme);
+ } catch (e) {
+ console.warn('Could not save theme preference:', e);
+ }
+ };
+
+ const loadThemePreference = () => {
+ try {
+ const saved = localStorage.getItem('wplace-theme');
+ if (saved && CONFIG.THEMES[saved]) {
+ CONFIG.currentTheme = saved;
+ }
+ } catch (e) {
+ console.warn('Could not load theme preference:', e);
+ }
+ };
+
+ // Dynamically loaded translations
+ window.loadedTranslations = {};
+
+ // Available languages
+ const AVAILABLE_LANGUAGES = [
+ 'en',
+ 'es',
+ 'ru',
+ 'pt',
+ 'vi',
+ 'fr',
+ 'id',
+ 'tr',
+ 'zh-CN',
+ 'zh-TW',
+ 'ja',
+ 'ko',
+ 'uk',
+ ];
+
+ // Function to load translations from JSON file with retry mechanism
+ const loadTranslations = async (language, retryCount = 0) => {
+ if (window.loadedTranslations[language]) {
+ return window.loadedTranslations[language];
+ }
+
+ console.group(`%cš Loading ${language.toUpperCase()} translations`, 'color: #06b6d4; font-weight: bold;');
+
+ // First try: Check if extension has loaded local resources
+ if (window.AUTOBOT_LANGUAGES && Object.keys(window.AUTOBOT_LANGUAGES).length > 0) {
+ console.log(`%cš Checking extension local resources...`, 'color: #06b6d4;');
+
+ const langFile = `${language}.json`;
+ const availableFiles = Object.keys(window.AUTOBOT_LANGUAGES || {});
+ const regionalMatch = availableFiles.find(file => file.toLowerCase().startsWith(`${language.toLowerCase()}-`));
+ const resolvedFile = window.AUTOBOT_LANGUAGES[langFile] ? langFile : regionalMatch;
+
+ if (resolvedFile && window.AUTOBOT_LANGUAGES[resolvedFile]) {
+ const translations = window.AUTOBOT_LANGUAGES[resolvedFile];
+
+ // Validate that translations is an object with keys
+ if (
+ typeof translations === 'object' &&
+ translations !== null &&
+ Object.keys(translations).length > 0
+ ) {
+ window.loadedTranslations[language] = translations;
+ console.log(`%cā
Loaded ${language} translations from EXTENSION LOCAL FILES`, 'color: #10b981; font-weight: bold;');
+ console.log(` š Source: Extension local storage (chrome-extension://)`);
+ if (resolvedFile !== langFile) {
+ console.log(` š Resolved locale: ${resolvedFile.replace('.json', '')}`);
+ }
+ console.log(` š Keys count: ${Object.keys(translations).length}`);
+ console.log(` š Performance: Instant load (no network request)`);
+ console.groupEnd();
+ return translations;
+ }
+ } else {
+ console.log(`%cš ${langFile} not found in extension resources`, 'color: #f59e0b;');
+ console.log(` š Available in extension: ${Object.keys(window.AUTOBOT_LANGUAGES).join(', ')}`);
+ }
+ } else {
+ console.log(`%cš No extension local resources available`, 'color: #f59e0b;');
+ console.log(` š window.AUTOBOT_LANGUAGES: ${typeof window.AUTOBOT_LANGUAGES}`);
+ }
+
+ // Second try: Use helper function if available
+ if (typeof window.getLanguage === 'function') {
+ console.log(`%cš§ Trying extension getLanguage() helper...`, 'color: #06b6d4;');
+ try {
+ const translations = window.getLanguage(language);
+ if (
+ typeof translations === 'object' &&
+ translations !== null &&
+ Object.keys(translations).length > 0
+ ) {
+ window.loadedTranslations[language] = translations;
+ console.log(`%cā
Loaded ${language} translations via extension helper`, 'color: #10b981; font-weight: bold;');
+ console.log(` š Source: Extension getLanguage() function`);
+ console.log(` š Keys count: ${Object.keys(translations).length}`);
+ console.groupEnd();
+ return translations;
+ }
+ } catch (error) {
+ console.warn(`%cā ļø Extension helper failed:`, 'color: #f59e0b;', error);
+ }
+ }
+
+ // Fallback: Load from CDN (original behavior)
+ console.log(`%cš Falling back to CDN loading...`, 'color: #8b5cf6;');
+ const url = `https://wplace-autobot.github.io/WPlace-AutoBOT/main/lang/${language}.json`;
+ const maxRetries = 3;
+ const baseDelay = 1000; // 1 second
+
+ try {
+ if (retryCount === 0) {
+ console.log(`š Loading ${language} translations from CDN...`);
+ } else {
+ console.log(
+ `š Retrying ${language} translations (attempt ${retryCount + 1}/${maxRetries + 1})...`
+ );
+ }
+
+ const response = await fetch(url);
+ if (response.ok) {
+ const translations = await response.json();
+
+ // Validate that translations is an object with keys
+ if (
+ typeof translations === 'object' &&
+ translations !== null &&
+ Object.keys(translations).length > 0
+ ) {
+ window.loadedTranslations[language] = translations;
+ console.log(
+ `%cš Loaded ${language} translations from CDN (${Object.keys(translations).length} keys)`, 'color: #f59e0b; font-weight: bold;'
+ );
+ console.log(` š Source: CDN (wplace-autobot.github.io)`);
+ console.log(` š URL: ${url}`);
+ console.log(` ā ļø Performance: Network request required`);
+ console.groupEnd();
+ return translations;
+ } else {
+ console.warn(`ā Invalid translation format for ${language}`);
+ throw new Error('Invalid translation format');
+ }
+ } else {
+ console.warn(
+ `ā CDN returned HTTP ${response.status}: ${response.statusText} for ${language} translations`
+ );
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error(
+ `ā Failed to load ${language} translations from CDN (attempt ${retryCount + 1}):`,
+ error
+ );
+
+ // Retry with exponential backoff
+ if (retryCount < maxRetries) {
+ const delay = baseDelay * Math.pow(2, retryCount);
+ console.log(`ā³ Retrying in ${delay}ms...`);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ console.groupEnd();
+ return loadTranslations(language, retryCount + 1);
+ }
+ }
+
+ console.error(`%cš„ All translation loading methods failed for ${language}`, 'color: #ef4444; font-weight: bold;');
+ console.groupEnd();
+ return null;
+ };
+
+ const loadLanguagePreference = async () => {
+ const savedLanguage = localStorage.getItem('wplace_language');
+ const browserLocale = navigator.language;
+ const browserLanguage = browserLocale.split('-')[0];
+
+ let selectedLanguage = 'en'; // Default fallback
+
+ try {
+ // Check if we have the saved language available
+ if (savedLanguage && AVAILABLE_LANGUAGES.includes(savedLanguage)) {
+ selectedLanguage = savedLanguage;
+ console.log(`š Using saved language preference: ${selectedLanguage}`);
+ }
+ // Try full locale match (e.g. "zh-CN", "zh-TW" etc)
+ else if (AVAILABLE_LANGUAGES.includes(browserLocale)) {
+ selectedLanguage = browserLocale;
+ localStorage.setItem('wplace_language', browserLocale);
+ console.log(`š Using browser locale: ${selectedLanguage}`);
+ }
+ // Try base language match (e.g. "en" for "en-US" or "en-GB" etc)
+ else if (AVAILABLE_LANGUAGES.includes(browserLanguage)) {
+ selectedLanguage = browserLanguage;
+ localStorage.setItem('wplace_language', browserLanguage);
+ console.log(`š Using browser language: ${selectedLanguage}`);
+ }
+ // Use English as fallback
+ else {
+ console.log(`š No matching language found, using English fallback`);
+ }
+
+ // Set the language in state first
+ state.language = selectedLanguage;
+
+ // Only load translations if not already loaded and not English (which should already be loaded)
+ if (selectedLanguage !== 'en' && !window.loadedTranslations[selectedLanguage]) {
+ const loaded = await loadTranslations(selectedLanguage);
+ if (!loaded) {
+ console.warn(
+ `ā ļø Failed to load ${selectedLanguage} translations, falling back to English`
+ );
+ state.language = 'en';
+ localStorage.setItem('wplace_language', 'en');
+ }
+ }
+ } catch (error) {
+ console.error(`ā Error in loadLanguagePreference:`, error);
+ state.language = 'en'; // Always ensure we have a valid language
+ }
+ };
+
+ // Simple user notification function for critical issues
+ const showTranslationWarning = (message) => {
+ try {
+ // Create a simple temporary notification banner
+ const warning = document.createElement('div');
+ warning.style.cssText = `
+ position: fixed; top: 10px; right: 10px; z-index: 10001;
+ background: rgba(255, 193, 7, 0.95); color: #212529; padding: 12px 16px;
+ border-radius: 8px; font-size: 14px; font-weight: 500;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3); border: 1px solid rgba(255, 193, 7, 0.8);
+ max-width: 300px; word-wrap: break-word;
+ `;
+ warning.textContent = message;
+ document.body.appendChild(warning);
+
+ // Auto-remove after 8 seconds
+ setTimeout(() => {
+ if (warning.parentNode) {
+ warning.remove();
+ }
+ }, 8000);
+ } catch (e) {
+ // If DOM manipulation fails, just log
+ console.warn('Failed to show translation warning UI:', e);
+ }
+ };
+
+ // Initialize translations function
+ const initializeTranslations = async () => {
+ try {
+ console.log('š Initializing translation system...');
+
+ // Always ensure English is loaded as fallback first
+ if (!window.loadedTranslations['en']) {
+ const englishLoaded = await loadTranslations('en');
+ if (!englishLoaded) {
+ console.warn('ā ļø Failed to load English translations from CDN, using fallback');
+ showTranslationWarning('ā ļø Translation loading failed, using basic fallbacks');
+ }
+ }
+
+ // Then load user's language preference
+ await loadLanguagePreference();
+
+ console.log(`ā
Translation system initialized. Active language: ${state.language}`);
+ } catch (error) {
+ console.error('ā Translation initialization failed:', error);
+ // Ensure state has a valid language even if loading fails
+ if (!state.language) {
+ state.language = 'en';
+ }
+ console.warn('ā ļø Using fallback translations due to initialization failure');
+ showTranslationWarning('ā ļø Translation system error, using basic English');
+ }
+ };
+
+ // Emergency fallback TEXT (minimal)
+ const FALLBACK_TEXT = {
+ en: {
+ title: 'WPlace Auto-Image',
+ toggleOverlay: 'Toggle Overlay',
+ scanColors: 'Scan Colors',
+ uploadImage: 'Upload Image',
+ resizeImage: 'Resize Image',
+ selectPosition: 'Select Position',
+ startPainting: 'Start Painting',
+ stopPainting: 'Stop Painting',
+ progress: 'Progress',
+ pixels: 'Pixels',
+ charges: 'Charges',
+ batchSize: 'Batch Size',
+ paintingOrder: 'Painting Order',
+ paintingOrderSequential: 'Sequential (Top to Bottom)',
+ paintingOrderColorByColor: 'Color by Color',
+ currentlyPaintingColor: 'Currently painting: {colorName}',
+ colorProgress: '{painted} of {total} {colorName} pixels',
+ cooldownSettings: 'Cooldown Settings',
+ waitCharges: 'Wait for Charges',
+ settings: 'Settings',
+ showStats: 'Show Statistics',
+ compactMode: 'Compact Mode',
+ minimize: 'Minimize',
+ tokenCapturedSuccess: 'Token captured successfully',
+ turnstileInstructions: 'Complete the verification',
+ hideTurnstileBtn: 'Hide',
+ notificationsNotSupported: 'Notifications not supported',
+ chargesReadyMessage: 'Charges are ready',
+ chargesReadyNotification: 'WPlace AutoBot',
+ initMessage: "Click 'Upload Image' to begin",
+ },
+ };
+
+ // Safe translation function with multiple fallback levels
+ const getText = (key, replacements = {}) => {
+ // Try current language first
+ let text = window.loadedTranslations[state.language]?.[key];
+
+ // Fallback to English translations
+ if (!text && state.language !== 'en') {
+ text = window.loadedTranslations['en']?.[key];
+ }
+
+ // Fallback to hardcoded English
+ if (!text) {
+ text = FALLBACK_TEXT['en']?.[key];
+ }
+
+ // Last resort - return the key itself
+ if (!text) {
+ console.warn(`ā ļø Missing translation for key: ${key}`);
+ return key;
+ }
+
+ // Handle string replacements like {count}, {time}, etc.
+ return Object.entries(replacements).reduce((result, [placeholder, value]) => {
+ return result.replace(new RegExp(`\\{${placeholder}\\}`, 'g'), value);
+ }, text);
+ };
+
+ // UNIFIED ACCOUNT MANAGER
+ class AccountManager {
+ constructor() {
+ this.accounts = [];
+ this.currentIndex = 0;
+ }
+
+ // Load accounts from storage
+ async loadAccounts() {
+ try {
+ // Get tokens from localStorage
+ const tokens = JSON.parse(localStorage.getItem("accounts")) || [];
+
+ // Get account info from storage
+ const infoAccountsResult = await new Promise((resolve) => {
+ if (typeof chrome !== 'undefined' && chrome.storage) {
+ chrome.storage.local.get('infoAccounts', (result) => {
+ resolve(result.infoAccounts || []);
+ });
+ } else {
+ // Fallback for when chrome storage isn't available
+ resolve([]);
+ }
+ });
+
+ console.log(`š„ [ACCOUNT MANAGER] Loading ${tokens.length} accounts`);
+
+ this.accounts = tokens.map((token, index) => {
+ const info = infoAccountsResult.find(acc => acc.token === token);
+ return {
+ token,
+ displayName: info?.displayName || info?.name || `Account ${index + 1}`,
+ ID: info?.ID || null,
+ Charges: info?.Charges || 0,
+ Max: info?.Max || 0,
+ Droplets: info?.Droplets || 0,
+ isCurrent: info?.isCurrent || false,
+ lastUsed: info?.lastUsed || null,
+ lastImported: info?.lastImported || null
+ };
+ });
+
+ // Find current account index
+ this.currentIndex = this.accounts.findIndex(acc => acc.isCurrent);
+ if (this.currentIndex === -1) this.currentIndex = 0;
+
+ console.log(`ā
[ACCOUNT MANAGER] Loaded ${this.accounts.length} accounts, current index: ${this.currentIndex}`);
+ console.log(`šÆ [ACCOUNT MANAGER] Current account: ${this.getCurrentAccount()?.displayName || 'None'}`);
+
+ // Auto-refresh account statuses after loading
+ if (this.accounts.length > 0) {
+ console.log('š [ACCOUNT MANAGER] Auto-refreshing account statuses...');
+ // Use setTimeout to avoid blocking the load operation
+ setTimeout(async () => {
+ try {
+ await fetchAllAccountDetails();
+ console.log('ā
[ACCOUNT MANAGER] Auto-refresh completed');
+ } catch (error) {
+ console.warn('ā ļø [ACCOUNT MANAGER] Auto-refresh failed:', error);
+ }
+ }, 100);
+ }
+
+ return this.accounts;
+ } catch (error) {
+ console.error('ā [ACCOUNT MANAGER] Error loading accounts:', error);
+ this.accounts = [];
+ this.currentIndex = 0;
+ return [];
+ }
+ }
+
+ // Simple linear switching
+ switchToNext() {
+ if (this.accounts.length === 0) return null;
+
+ const previousIndex = this.currentIndex;
+ this.currentIndex = (this.currentIndex + 1) % this.accounts.length;
+
+ console.log(`š [ACCOUNT MANAGER] Switching: ${previousIndex} ā ${this.currentIndex}`);
+ console.log(`šÆ [ACCOUNT MANAGER] Target: ${this.accounts[this.currentIndex].displayName}`);
+
+ // Update current flags
+ this.accounts.forEach((acc, idx) => {
+ acc.isCurrent = idx === this.currentIndex;
+ });
+
+ return this.accounts[this.currentIndex];
+ }
+
+ getCurrentAccount() {
+ return this.accounts[this.currentIndex] || null;
+ }
+
+ getAccountByIndex(index) {
+ return this.accounts[index] || null;
+ }
+
+ updateAccountData(tokenOrData, dataObject = null) {
+ let accountData;
+ let targetAccount;
+
+ if (dataObject !== null) {
+ // Called with (token, dataObject) format
+ const token = tokenOrData;
+ accountData = dataObject;
+ targetAccount = this.accounts.find(acc => acc.token === token);
+ if (!targetAccount) {
+ console.warn(`ā ļø [ACCOUNT MANAGER] Account with token not found for update`);
+ return;
+ }
+ } else {
+ // Called with (dataObject) format - update current account
+ accountData = tokenOrData;
+ targetAccount = this.getCurrentAccount();
+ }
+
+ if (targetAccount && accountData) {
+ // Update all provided properties
+ if (accountData.charges !== undefined || accountData.Charges !== undefined) {
+ targetAccount.Charges = Math.floor(accountData.charges || accountData.Charges || 0);
+ }
+ if (accountData.max !== undefined || accountData.Max !== undefined) {
+ targetAccount.Max = Math.floor(accountData.max || accountData.Max || 0);
+ }
+ if (accountData.droplets !== undefined || accountData.Droplets !== undefined) {
+ targetAccount.Droplets = Math.floor(accountData.droplets || accountData.Droplets || 0);
+ }
+ if (accountData.id !== undefined || accountData.ID !== undefined) {
+ targetAccount.ID = accountData.id || accountData.ID || targetAccount.ID;
+ }
+ if (accountData.displayName !== undefined) {
+ targetAccount.displayName = accountData.displayName;
+ }
+ if (accountData.isCurrent !== undefined) {
+ // Clear isCurrent from all accounts first, then set for target
+ this.accounts.forEach(acc => acc.isCurrent = false);
+ targetAccount.isCurrent = accountData.isCurrent;
+ }
+ if (accountData.cooldown !== undefined) {
+ targetAccount.cooldown = accountData.cooldown;
+ }
+
+ console.log(`š [ACCOUNT MANAGER] Updated ${targetAccount.displayName}: ā”${targetAccount.Charges}/${targetAccount.Max} š§${targetAccount.Droplets}${targetAccount.isCurrent ? ' (CURRENT)' : ''}`);
+ }
+ }
+
+ getAccountCount() {
+ return this.accounts.length;
+ }
+
+ getAllAccounts() {
+ return [...this.accounts]; // Return copy to prevent direct mutation
+ }
+
+ setCurrentIndex(index) {
+ if (index >= 0 && index < this.accounts.length) {
+ this.currentIndex = index;
+ console.log(`š [ACCOUNT MANAGER] Current index set to: ${index}`);
+ } else {
+ console.warn(`ā ļø [ACCOUNT MANAGER] Invalid index ${index}, keeping current: ${this.currentIndex}`);
+ }
+ }
+
+ getNextAccount() {
+ if (this.accounts.length === 0) return null;
+ const nextIndex = (this.currentIndex + 1) % this.accounts.length;
+ return this.accounts[nextIndex];
+ }
+ }
+
+ // Create global account manager instance
+ const accountManager = new AccountManager();
+
+ // GLOBAL STATE
+ const state = {
+ running: false,
+ imageLoaded: false,
+ processing: false,
+ totalPixels: 0,
+ paintedPixels: 0,
+ preFilteringDone: false, // Track if pre-filtering detection has been done this session
+ progressResetDone: false, // Track if progress reset has been done this save file session
+ availableColors: [],
+ activeColorPalette: [], // User-selected colors for conversion
+ paintWhitePixels: true, // Default to ON
+ fullChargeData: null,
+ fullChargeInterval: null,
+ paintTransparentPixels: false, // Default to OFF
+ displayCharges: 0,
+ preciseCurrentCharges: 0,
+ maxCharges: 1, // Default max charges
+ cooldown: CONFIG.COOLDOWN_DEFAULT,
+ imageData: null,
+ stopFlag: false,
+ colorsChecked: false,
+ startPosition: null,
+ selectingPosition: false,
+ region: null,
+ minimized: false,
+ lastPosition: { x: 0, y: 0 },
+ lastPaintedPosition: { x: 0, y: 0 }, // Track last successfully painted coordinate
+ estimatedTime: 0,
+ language: 'en',
+ paintingSpeed: CONFIG.PAINTING_SPEED.DEFAULT, // pixels batch size
+ batchMode: CONFIG.BATCH_MODE, // "normal" or "random"
+ paintingOrder: CONFIG.PAINTING_ORDER, // "sequential" or "color-by-color"
+ currentPaintingColor: null, // Track current color being painted in color-by-color mode
+ randomBatchMin: CONFIG.RANDOM_BATCH_RANGE.MIN, // Random range minimum
+ randomBatchMax: CONFIG.RANDOM_BATCH_RANGE.MAX, // Random range maximum
+ cooldownChargeThreshold: CONFIG.COOLDOWN_CHARGE_THRESHOLD,
+ chargesThresholdInterval: null,
+ tokenSource: CONFIG.TOKEN_SOURCE, // "generator" or "manual"
+ initialSetupComplete: false, // Track if initial startup setup is complete (only happens once)
+ overlayOpacity: CONFIG.OVERLAY.OPACITY_DEFAULT,
+ blueMarbleEnabled: CONFIG.OVERLAY.BLUE_MARBLE_DEFAULT,
+ ditheringEnabled: false,
+ // Advanced color matching settings
+ colorMatchingAlgorithm: 'lab',
+ enableChromaPenalty: true,
+ chromaPenaltyWeight: 0.15,
+ customTransparencyThreshold: CONFIG.TRANSPARENCY_THRESHOLD,
+ customWhiteThreshold: CONFIG.WHITE_THRESHOLD,
+ resizeSettings: null,
+ originalImage: null,
+ resizeIgnoreMask: null,
+ paintUnavailablePixels: CONFIG.PAINT_UNAVAILABLE,
+ // Coordinate generation settings
+ coordinateMode: CONFIG.COORDINATE_MODE,
+ coordinateDirection: CONFIG.COORDINATE_DIRECTION,
+ coordinateSnake: CONFIG.COORDINATE_SNAKE,
+ blockWidth: CONFIG.COORDINATE_BLOCK_WIDTH,
+ blockHeight: CONFIG.COORDINATE_BLOCK_HEIGHT,
+ notificationsEnabled: CONFIG.NOTIFICATIONS.ENABLED,
+ notifyOnChargesReached: CONFIG.NOTIFICATIONS.ON_CHARGES_REACHED,
+ notifyOnlyWhenUnfocused: CONFIG.NOTIFICATIONS.ONLY_WHEN_UNFOCUSED,
+ notificationIntervalMinutes: CONFIG.NOTIFICATIONS.REPEAT_MINUTES,
+ _lastChargesNotifyAt: 0,
+ _lastChargesBelow: true,
+ // Smart save tracking
+ _lastSavePixelCount: 0,
+ _lastSaveTime: 0,
+ _saveInProgress: false,
+ paintedMap: null,
+ accountIndex: 0, // Keep for backward compatibility with existing logic
+ // Legacy state removed - now using accountManager instead
+ isFetchingAllAccounts: false,
+ };
+
+ // Expose state globally for the utils manager and other modules
+ window.state = state;
+
+ let _updateResizePreview = () => { };
+ let _resizeDialogCleanup = null;
+
+ // --- OVERLAY UPDATE: Optimized OverlayManager class with performance improvements ---
+ // OverlayManager class extracted to overlay-manager.js module
+
+ // Overlay management is now handled by the OverlayManager module
+ // Check for overlay manager availability
+ if (!window.WPlaceOverlayManager) {
+ console.error('ā WPlaceOverlayManager not available - please ensure overlay-manager.js is loaded first');
+ throw new Error('OverlayManager dependency not found');
+ }
+
+ // Ensure we only have ONE overlay manager instance globally
+ let overlayManager;
+ if (!window.autoImageOverlayManager) {
+ overlayManager = new window.WPlaceOverlayManager();
+ window.autoImageOverlayManager = overlayManager;
+ console.log('šÆ Created NEW overlay manager instance');
+ } else {
+ overlayManager = window.autoImageOverlayManager;
+ console.log('šÆ Using EXISTING overlay manager instance');
+ }
+
+ // Token management is now handled by the TokenManager module
+ // Global references for backward compatibility
+ if (!window.WPlaceTokenManager) {
+ console.error('ā WPlaceTokenManager not available - please ensure token-manager.js is loaded first');
+ throw new Error('TokenManager dependency not found');
+ }
+
+ const tokenManager = window.WPlaceTokenManager;
+ const setTurnstileToken = (token) => tokenManager.setTurnstileToken(token);
+ const isTokenValid = () => tokenManager.isTokenValid();
+ const invalidateToken = () => tokenManager.invalidateToken();
+ const ensureToken = (forceRefresh = false) => tokenManager.ensureToken(forceRefresh);
+
+ // Getter for the current token value
+ const getTurnstileToken = () => tokenManager.turnstileToken;
+
+ // Token promise management
+ const createTokenPromise = () => {
+ tokenManager.tokenPromise = new Promise((resolve) => {
+ tokenManager._resolveToken = resolve;
+ });
+ };
+
+ // Create global image processor instance for utility functions
+ if (!window.WPlaceImageProcessor) {
+ console.error('ā WPlaceImageProcessor not available - please ensure image-processor.js is loaded first');
+ throw new Error('ImageProcessor dependency not found');
+ }
+
+ const globalImageProcessor = new window.WPlaceImageProcessor();
+
+ // Keep these constants for compatibility with other parts of the script
+ let retryCount = 0;
+ const MAX_RETRIES = 10;
+ const MAX_BATCH_RETRIES = 10; // Maximum attempts for batch sending
+
+ function inject(callback) {
+ const script = document.createElement('script');
+ script.textContent = `(${callback})();`;
+ document.documentElement?.appendChild(script);
+ script.remove();
+ }
+
+ inject(() => {
+ const fetchedBlobQueue = new Map();
+
+ window.addEventListener('message', (event) => {
+ const { source, blobID, blobData } = event.data;
+ if (source === 'auto-image-overlay' && blobID && blobData) {
+ const callback = fetchedBlobQueue.get(blobID);
+ if (typeof callback === 'function') {
+ callback(blobData);
+ }
+ fetchedBlobQueue.delete(blobID);
+ }
+ });
+
+ const originalFetch = window.fetch;
+ // Setup fetch interceptions
+
+ window.fetch = async function (...args) {
+ const response = await originalFetch.apply(this, args);
+ const url = args[0] instanceof Request ? args[0].url : args[0];
+
+ // TILE REQUEST logging removed to reduce console spam
+
+ if (typeof url === 'string') {
+ if (url.includes('https://backend.wplace.live/s0/pixel/')) {
+ try {
+ const payload = JSON.parse(args[1].body);
+ if (payload.t) {
+ console.log(
+ `šā
Turnstile Token Captured - Type: ${typeof payload.t}, Value: ${payload.t
+ ? typeof payload.t === 'string'
+ ? payload.t.length > 50
+ ? payload.t.substring(0, 50) + '...'
+ : payload.t
+ : JSON.stringify(payload.t)
+ : 'null/undefined'
+ }, Length: ${payload.t?.length || 0}`
+ );
+ window.postMessage({ source: 'turnstile-capture', token: payload.t }, '*');
+ }
+ } catch (_) {
+ /* ignore */
+ }
+ }
+
+ const contentType = response.headers.get('content-type') || '';
+ if (contentType.includes('image/png') && url.includes('.png')) {
+ const cloned = response.clone();
+ return new Promise(async (resolve) => {
+ const blobUUID = crypto.randomUUID();
+ const originalBlob = await cloned.blob();
+
+ fetchedBlobQueue.set(blobUUID, (processedBlob) => {
+ resolve(
+ new Response(processedBlob, {
+ headers: cloned.headers,
+ status: cloned.status,
+ statusText: cloned.statusText,
+ })
+ );
+ });
+
+ window.postMessage(
+ {
+ source: 'auto-image-tile',
+ endpoint: url,
+ blobID: blobUUID,
+ blobData: originalBlob,
+ },
+ '*'
+ );
+ });
+ }
+ }
+
+ return response;
+ };
+ });
+
+ window.addEventListener('message', (event) => {
+ const { source, endpoint, blobID, blobData } = event.data;
+
+ if (source === 'auto-image-tile' && endpoint && blobID && blobData) {
+ overlayManager.processAndRespondToTileRequest(event.data);
+ }
+
+ // Token capture is now handled by TokenManager module
+ // No need to handle 'turnstile-capture' events here
+ });
+
+ async function detectLanguage() {
+ try {
+ const response = await fetch('https://backend.wplace.live/me', {
+ credentials: 'include',
+ });
+ const data = await response.json();
+ state.language = data.language === 'pt' ? 'pt' : 'en';
+ } catch {
+ state.language = navigator.language.startsWith('pt') ? 'pt' : 'en';
+ }
+ }
+
+ // UTILITY FUNCTIONS WRAPPER - Uses WPlaceUtilsManager for modular functionality
+ const Utils = {
+ // Basic utilities
+ sleep: (ms) => window.globalUtilsManager ? window.globalUtilsManager.sleep(ms) : new Promise(r => setTimeout(r, ms)),
+ dynamicSleep: (tickAndGetRemainingMs) => window.globalUtilsManager ? window.globalUtilsManager.dynamicSleep(tickAndGetRemainingMs) : Promise.resolve(),
+ waitForSelector: (selector, interval, timeout) => window.globalUtilsManager ? window.globalUtilsManager.waitForSelector(selector, interval, timeout) : Promise.resolve(null),
+ msToTimeText: (ms) => window.globalUtilsManager ? window.globalUtilsManager.msToTimeText(ms) : `${Math.ceil(ms / 1000)}s`,
+ createScrollToAdjust: (element, updateCallback, min, max, step) => window.globalUtilsManager ? window.globalUtilsManager.createScrollToAdjust(element, updateCallback, min, max, step) : () => { },
+
+ // Tile calculation
+ calculateTileRange: (startRegionX, startRegionY, startPixelX, startPixelY, width, height, tileSize) =>
+ window.globalUtilsManager ? window.globalUtilsManager.calculateTileRange(startRegionX, startRegionY, startPixelX, startPixelY, width, height, tileSize) : {},
+
+ // Token management
+ loadTurnstile: () => window.globalUtilsManager ? window.globalUtilsManager.loadTurnstile() : Promise.resolve(),
+ ensureTurnstileContainer: () => window.globalUtilsManager ? window.globalUtilsManager.ensureTurnstileContainer() : Promise.resolve(),
+ ensureTurnstileOverlayContainer: () => window.globalUtilsManager ? window.globalUtilsManager.ensureTurnstileOverlayContainer() : Promise.resolve(),
+ executeTurnstile: (sitekey, action) => window.globalUtilsManager ? window.globalUtilsManager.executeTurnstile(sitekey, action) : Promise.resolve(null),
+ createTurnstileWidget: (sitekey, action) => window.globalUtilsManager ? window.globalUtilsManager.createTurnstileWidget(sitekey, action) : Promise.resolve(),
+ createTurnstileWidgetInteractive: (sitekey, action) => window.globalUtilsManager ? window.globalUtilsManager.createTurnstileWidgetInteractive(sitekey, action) : Promise.resolve(),
+ cleanupTurnstile: () => window.globalUtilsManager ? window.globalUtilsManager.cleanupTurnstile() : Promise.resolve(),
+ obtainSitekeyAndToken: (fallback) => window.globalUtilsManager ? window.globalUtilsManager.obtainSitekeyAndToken(fallback) : Promise.resolve(fallback),
+
+ // DOM utilities
+ createElement: (tag, props, children) => window.globalUtilsManager ? window.globalUtilsManager.createElement(tag, props, children) : document.createElement(tag),
+ createButton: (id, text, icon, onClick, style) => {
+ if (window.globalUtilsManager) {
+ return window.globalUtilsManager.createButton(id, text, icon, onClick, style);
+ }
+ const btn = document.createElement('button');
+ btn.id = id;
+ btn.innerHTML = `${icon ? `` : ''}${text}`;
+ if (onClick) btn.addEventListener('click', onClick);
+ return btn;
+ },
+
+ // Translation with fallback
+ t: (key, params) => {
+ if (window.globalUtilsManager) {
+ return window.globalUtilsManager.t(key, params);
+ }
+ return getText(key, params);
+ },
+ showAlert: (message, type) => {
+ if (window.globalUtilsManager) {
+ window.globalUtilsManager.showAlert(message, type);
+ } else {
+ console.log(`Alert [${type}]: ${message}`);
+ }
+ },
+
+ hideAlert: () => {
+ // Remove existing alerts immediately with safety checks
+ const alerts = document.querySelectorAll('.wplace-alert-base');
+ alerts.forEach(alert => {
+ try {
+ if (alert && alert.parentNode) {
+ alert.parentNode.removeChild(alert);
+ }
+ } catch (error) {
+ // Ignore removeChild errors if element was already removed
+ console.debug('Alert already removed:', error);
+ }
+ });
+ },
+
+ // Color utilities
+ colorDistance: (a, b) => window.globalUtilsManager ? window.globalUtilsManager.colorDistance(a, b) : Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2)),
+ findClosestPaletteColor: (r, g, b, palette) => window.globalUtilsManager ? window.globalUtilsManager.findClosestPaletteColor(r, g, b, palette) : [r, g, b],
+ isWhitePixel: (r, g, b) => window.globalUtilsManager ? window.globalUtilsManager.isWhitePixel(r, g, b) : (r > 240 && g > 240 && b > 240),
+ resolveColor: (targetRgb, availableColors, exactMatch) => window.globalUtilsManager ? window.globalUtilsManager.resolveColor(targetRgb, availableColors, exactMatch) : targetRgb,
+
+ // CRITICAL FUNCTIONS - Direct implementations to ensure they work
+ createImageUploader: () => {
+ return new Promise((resolve, reject) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.onchange = (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(new Error('File reading error'));
+ reader.readAsDataURL(file);
+ } else {
+ reject(new Error('No file selected'));
+ }
+ };
+ input.click();
+ });
+ },
+
+ createFileUploader: () => {
+ return new Promise((resolve, reject) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.onchange = (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ const data = JSON.parse(reader.result);
+ resolve(data);
+ } catch (error) {
+ reject(new Error('Invalid JSON file'));
+ }
+ };
+ reader.onerror = () => reject(new Error('File reading error'));
+ reader.readAsText(file);
+ } else {
+ reject(new Error('No file selected'));
+ }
+ };
+ input.click();
+ });
+ },
+
+ createFileDownloader: (data, filename) => {
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ },
+
+ loadProgress: () => {
+ try {
+ const saved = localStorage.getItem('wplace-bot-progress');
+ if (!saved) return null;
+ return JSON.parse(saved);
+ } catch (error) {
+ console.error('Error loading progress:', error);
+ return null;
+ }
+ },
+
+ saveProgress: () => {
+ try {
+ // Use utils manager to build proper save structure like old version
+ const data = window.globalUtilsManager ?
+ window.globalUtilsManager.buildProgressData() :
+ {
+ timestamp: Date.now(),
+ version: '2.2',
+ state: {
+ totalPixels: window.state.totalPixels,
+ paintedPixels: window.state.paintedPixels,
+ lastPosition: window.state.lastPosition,
+ startPosition: window.state.startPosition,
+ region: window.state.region,
+ imageLoaded: window.state.imageLoaded,
+ colorsChecked: window.state.colorsChecked,
+ coordinateMode: window.state.coordinateMode,
+ coordinateDirection: window.state.coordinateDirection,
+ coordinateSnake: window.state.coordinateSnake,
+ blockWidth: window.state.blockWidth,
+ blockHeight: window.state.blockHeight,
+ availableColors: window.state.availableColors,
+ },
+ imageData: window.state.imageData ? {
+ width: window.state.imageData.width,
+ height: window.state.imageData.height,
+ pixels: Array.from(window.state.imageData.pixels),
+ totalPixels: window.state.imageData.totalPixels,
+ } : null,
+ paintedMapPacked: window.globalUtilsManager ?
+ window.globalUtilsManager.buildPaintedMapPacked() : null
+ };
+
+ // Attempt to save; if payload is too large (QuotaExceeded), retry without heavy pixel data
+ try {
+ localStorage.setItem('wplace-bot-progress', JSON.stringify(data));
+ return true;
+ } catch (e) {
+ // Fallback: strip image pixels to reduce size and retry
+ if (data && data.imageData && data.imageData.pixels) {
+ const slimData = {
+ ...data,
+ imageData: {
+ width: data.imageData.width,
+ height: data.imageData.height,
+ totalPixels: data.imageData.totalPixels,
+ pixelsStripped: true,
+ }
+ };
+ try {
+ localStorage.setItem('wplace-bot-progress', JSON.stringify(slimData));
+ console.warn('Saved progress without raw pixel data due to storage quota limits.');
+ return true;
+ } catch (e2) {
+ try {
+ // Last resort: try sessionStorage
+ sessionStorage.setItem('wplace-bot-progress', JSON.stringify(slimData));
+ console.warn('Saved progress to sessionStorage without pixel data due to storage quota limits.');
+ return true;
+ } catch (e3) {
+ throw e2; // will be handled by outer catch
+ }
+ }
+ }
+ throw e;
+ }
+ } catch (error) {
+ console.error('Error saving progress:', error);
+ return false;
+ }
+ },
+
+ clearProgress: () => {
+ try {
+ localStorage.removeItem('wplace-bot-progress');
+ if (window.state) {
+ window.state.paintedMap = null;
+ window.state._lastSavePixelCount = 0;
+ window.state._lastSaveTime = 0;
+ }
+ return true;
+ } catch (error) {
+ console.error('Error clearing progress:', error);
+ return false;
+ }
+ },
+
+ restoreProgress: (savedData) => {
+ try {
+ console.log('š [DEBUG] RestoreProgress: Starting restoration...');
+ console.log('š [DEBUG] SavedData exists:', !!savedData);
+ console.log('š [DEBUG] SavedData.state exists:', !!savedData?.state);
+
+ if (!savedData || !savedData.state) {
+ console.error('ā [DEBUG] RestoreProgress: Invalid savedData or missing state');
+ return false;
+ }
+
+ console.log('š [DEBUG] Current window.state keys before restore:', Object.keys(window.state || {}));
+ console.log('š [DEBUG] SavedData.state keys:', Object.keys(savedData.state));
+
+ console.log('š [DEBUG] Performing Object.assign...');
+ Object.assign(window.state, savedData.state);
+
+ // Reset session-specific flags when loading a new save file
+ window.state.preFilteringDone = false;
+ window.state.progressResetDone = false;
+ console.log('š Reset session flags for new save file load');
+
+ console.log('ā
[DEBUG] Object.assign completed successfully');
+
+ // Restore available colors from old save files (backward compatibility)
+ if (savedData.state.availableColors && Array.isArray(savedData.state.availableColors)) {
+ console.log('š [DEBUG] Restoring availableColors, count:', savedData.state.availableColors.length);
+ window.state.availableColors = savedData.state.availableColors;
+ window.state.colorsChecked = true; // Mark colors as checked
+ console.log('ā
[DEBUG] AvailableColors restored successfully');
+ } else {
+ console.log('ā ļø [DEBUG] No availableColors found in savedData or not an array');
+ }
+
+ if (savedData.imageData && Array.isArray(savedData.imageData.pixels)) {
+ console.log('š [DEBUG] Restoring imageData...');
+ console.log('š [DEBUG] ImageData type check - pixels is array:', Array.isArray(savedData.imageData.pixels));
+ console.log('š [DEBUG] ImageData pixels length:', savedData.imageData.pixels?.length);
+
+ window.state.imageData = {
+ ...savedData.imageData,
+ pixels: new Uint8ClampedArray(savedData.imageData.pixels),
+ };
+ console.log('ā
[DEBUG] ImageData restored successfully');
+ console.log('š [DEBUG] Converted pixels to Uint8ClampedArray, length:', window.state.imageData.pixels.length);
+ } else if (savedData.imageData && savedData.imageData.pixelsRef) {
+ console.warn('ā¹ļø [DEBUG] Large image detected; loading pixels from IndexedDB via ref:', savedData.imageData.pixelsRef);
+ window.state.imageData = {
+ width: savedData.imageData.width,
+ height: savedData.imageData.height,
+ totalPixels: savedData.imageData.totalPixels,
+ pixels: null,
+ };
+ window.state.imageLoaded = false;
+
+ if (window.globalUtilsManager && typeof window.globalUtilsManager.loadPixelsFromIndexedDB === 'function') {
+ window.globalUtilsManager.loadPixelsFromIndexedDB(savedData.imageData.pixelsRef)
+ .then(payload => {
+ if (!payload || !payload.pixels) {
+ console.warn('ā ļø [DEBUG] Pixels not found in IDB. Ask user to reload image.');
+ return;
+ }
+ window.state.imageData.pixels = new Uint8ClampedArray(payload.pixels);
+ window.state.imageLoaded = true;
+ console.log('ā
[DEBUG] Pixels loaded from IDB');
+
+ // Try to restore overlay immediately
+ if (typeof window.globalUtilsManager.restoreOverlayFromData === 'function') {
+ window.globalUtilsManager.restoreOverlayFromData().catch(() => {});
+ }
+ })
+ .catch(err => console.warn('ā [DEBUG] Failed to load pixels from IDB:', err));
+ }
+ } else if (savedData.imageData && savedData.imageData.pixelsStripped) {
+ console.warn('ā ļø [DEBUG] Saved progress did not include raw pixel data due to quota limits. Please reload the image file to resume.');
+ // Preserve dimensions for UI, but mark image as not loaded
+ window.state.imageData = {
+ width: savedData.imageData.width,
+ height: savedData.imageData.height,
+ totalPixels: savedData.imageData.totalPixels,
+ pixels: null,
+ };
+ window.state.imageLoaded = false;
+ } else {
+ console.log('ā ļø [DEBUG] No imageData found in savedData');
+ }
+
+ if (savedData.paintedMap) {
+ console.log('š [DEBUG] Restoring paintedMap...');
+ window.state.paintedMap = savedData.paintedMap.map((row) => Array.from(row));
+ console.log('ā
[DEBUG] PaintedMap restored successfully');
+ } else {
+ console.log('š [DEBUG] No paintedMap found in savedData (this is normal for extracted data)');
+ }
+
+ console.log('ā
[DEBUG] RestoreProgress completed successfully');
+ console.log('š [DEBUG] Final window.state.imageLoaded:', window.state.imageLoaded);
+ console.log('š [DEBUG] Final window.state.totalPixels:', window.state.totalPixels);
+
+ return true;
+ } catch (error) {
+ console.error('ā [DEBUG] Error in restoreProgress:', error);
+ console.error('ā [DEBUG] Error stack:', error.stack);
+ console.error('ā [DEBUG] SavedData structure at error:', JSON.stringify(savedData, null, 2));
+ return false;
+ }
+ },
+
+ saveProgressToFile: () => {
+ try {
+ // Use utils manager to build proper save structure like old version
+ const progressData = window.globalUtilsManager ?
+ window.globalUtilsManager.buildProgressData() :
+ {
+ timestamp: Date.now(),
+ version: '2.2',
+ state: {
+ totalPixels: window.state.totalPixels,
+ paintedPixels: window.state.paintedPixels,
+ lastPosition: window.state.lastPosition,
+ startPosition: window.state.startPosition,
+ region: window.state.region,
+ imageLoaded: window.state.imageLoaded,
+ colorsChecked: window.state.colorsChecked,
+ coordinateMode: window.state.coordinateMode,
+ coordinateDirection: window.state.coordinateDirection,
+ coordinateSnake: window.state.coordinateSnake,
+ blockWidth: window.state.blockWidth,
+ blockHeight: window.state.blockHeight,
+ availableColors: window.state.availableColors,
+ },
+ imageData: window.state.imageData ? {
+ width: window.state.imageData.width,
+ height: window.state.imageData.height,
+ pixels: Array.from(window.state.imageData.pixels),
+ totalPixels: window.state.imageData.totalPixels,
+ } : null,
+ paintedMapPacked: window.globalUtilsManager ?
+ window.globalUtilsManager.buildPaintedMapPacked() : null
+ };
+
+ const filename = `wplace-bot-progress-${new Date()
+ .toISOString()
+ .slice(0, 19)
+ .replace(/:/g, '-')}.json`;
+ Utils.createFileDownloader(JSON.stringify(progressData, null, 2), filename);
+ return true;
+ } catch (error) {
+ console.error('Error saving to file:', error);
+ return false;
+ }
+ },
+
+ loadProgressFromFile: async () => {
+ try {
+ console.log('š [DEBUG] Starting file upload process...');
+ const data = await Utils.createFileUploader();
+
+ console.log('š [DEBUG] File upload completed. Data type:', typeof data);
+ console.log('š [DEBUG] Data exists:', !!data);
+
+ if (!data) {
+ console.error('ā [DEBUG] No data received from file uploader');
+ throw new Error('No data received from file');
+ }
+
+ console.log('š [DEBUG] Data keys:', Object.keys(data));
+ console.log('š [DEBUG] Data.state exists:', !!data.state);
+ console.log('š [DEBUG] Data.imageData exists:', !!data.imageData);
+ console.log('š [DEBUG] Data.version:', data.version);
+ console.log('š [DEBUG] Data.timestamp:', data.timestamp);
+
+ if (!data.state) {
+ console.error('ā [DEBUG] Missing state object in loaded data');
+ console.log('š [DEBUG] Available top-level keys:', Object.keys(data));
+ throw new Error('Invalid file format - missing state object');
+ }
+
+ console.log('š [DEBUG] State object keys:', Object.keys(data.state));
+ console.log('š [DEBUG] State.imageLoaded:', data.state.imageLoaded);
+ console.log('š [DEBUG] State.totalPixels:', data.state.totalPixels);
+ console.log('š [DEBUG] State.startPosition:', data.state.startPosition);
+ console.log('š [DEBUG] State.region:', data.state.region);
+
+ if (data.imageData) {
+ console.log('š [DEBUG] ImageData width:', data.imageData.width);
+ console.log('š [DEBUG] ImageData height:', data.imageData.height);
+ console.log('š [DEBUG] ImageData pixels length:', data.imageData.pixels?.length);
+ console.log('š [DEBUG] ImageData totalPixels:', data.imageData.totalPixels);
+ }
+
+ console.log('š [DEBUG] Calling restoreProgress...');
+ const success = Utils.restoreProgress(data);
+ console.log('š [DEBUG] RestoreProgress result:', success);
+
+ return success;
+ } catch (error) {
+ console.error('ā [DEBUG] Error in loadProgressFromFile:', error);
+ console.error('ā [DEBUG] Error stack:', error.stack);
+ return false;
+ }
+ },
+
+ loadExtractedFileData: async () => {
+ try {
+ console.log('š [DEBUG] Starting Art-Extractor file upload process...');
+ const data = await Utils.createFileUploader();
+
+ console.log('š [DEBUG] File upload completed. Data type:', typeof data);
+ console.log('š [DEBUG] Data exists:', !!data);
+ console.log('š Raw loaded data:', data);
+
+ if (!data) {
+ console.error('ā [DEBUG] No data received from file uploader');
+ return null;
+ }
+
+ console.log('š [DEBUG] Data keys:', Object.keys(data));
+ console.log('š [DEBUG] Data.state exists:', !!data.state);
+ console.log('š [DEBUG] Data.imageData exists:', !!data.imageData);
+ console.log('š [DEBUG] Data.version:', data.version);
+ console.log('š [DEBUG] Data.timestamp:', data.timestamp);
+
+ console.log('š [DEBUG] Returning raw data object for Load Extracted feature');
+ return data;
+ } catch (error) {
+ console.error('ā [DEBUG] Error in loadExtractedFileData:', error);
+ return null;
+ }
+ },
+
+ // Other utilities - delegate to globalUtilsManager or provide simple fallbacks
+ extractAvailableColors: () => {
+ if (window.globalUtilsManager) {
+ return window.globalUtilsManager.extractAvailableColors();
+ } else if (window.globalImageProcessor) {
+ return window.globalImageProcessor.extractAvailableColors(window.CONFIG.COLOR_MAP);
+ } else {
+ return [];
+ }
+ },
+ formatTime: (ms) => window.globalUtilsManager ? window.globalUtilsManager.formatTime(ms) : `${Math.floor(ms / 1000)}s`,
+ calculateEstimatedTime: (remainingPixels, charges, cooldown) => window.globalUtilsManager ? window.globalUtilsManager.calculateEstimatedTime(remainingPixels, charges, cooldown) : 0,
+ initializePaintedMap: (width, height) => window.globalUtilsManager ? window.globalUtilsManager.initializePaintedMap(width, height) : console.log('Painted map not available'),
+ markPixelPainted: (x, y, regionX, regionY) => window.globalUtilsManager ? window.globalUtilsManager.markPixelPainted(x, y, regionX, regionY) : false,
+ isPixelPainted: (x, y, regionX, regionY) => window.globalUtilsManager ? window.globalUtilsManager.isPixelPainted(x, y, regionX, regionY) : false,
+ shouldAutoSave: () => window.globalUtilsManager ? window.globalUtilsManager.shouldAutoSave() : false,
+ performSmartSave: () => window.globalUtilsManager ? window.globalUtilsManager.performSmartSave() : false,
+ packPaintedMapToBase64: (paintedMap, width, height) => window.globalUtilsManager ? window.globalUtilsManager.packPaintedMapToBase64(paintedMap, width, height) : null,
+ unpackPaintedMapFromBase64: (base64, width, height) => window.globalUtilsManager ? window.globalUtilsManager.unpackPaintedMapFromBase64(base64, width, height) : null,
+ migrateProgressToV2: (saved) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgressToV2(saved) : saved,
+ migrateProgressToV21: (saved) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgressToV21(saved) : saved,
+ migrateProgressToV22: (data) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgressToV22(data) : data,
+ buildPaintedMapPacked: () => window.globalUtilsManager ? window.globalUtilsManager.buildPaintedMapPacked() : null,
+ buildProgressData: () => window.globalUtilsManager ? window.globalUtilsManager.buildProgressData() : {},
+ migrateProgress: (saved) => window.globalUtilsManager ? window.globalUtilsManager.migrateProgress(saved) : saved,
+ restoreOverlayFromData: () => window.globalUtilsManager ? window.globalUtilsManager.restoreOverlayFromData() : Promise.resolve(false),
+ updateCoordinateUI: (config) => window.globalUtilsManager ? window.globalUtilsManager.updateCoordinateUI(config) : console.log('Coordinate UI update not available'),
+ };
+
+ // IMAGE PROCESSOR WRAPPER - Uses WPlaceImageProcessor for modular functionality
+ class ImageProcessor {
+ constructor(imageSrc) {
+ // Create instance of the modular ImageProcessor
+ this._processor = new window.WPlaceImageProcessor(imageSrc);
+
+ // Legacy compatibility properties
+ this.imageSrc = imageSrc;
+ this.img = null;
+ this.canvas = null;
+ this.ctx = null;
+ }
+
+ async load() {
+ await this._processor.load();
+
+ // Update legacy properties for compatibility
+ this.img = this._processor.img;
+ this.canvas = this._processor.canvas;
+ this.ctx = this._processor.ctx;
+ }
+
+ getDimensions() {
+ return this._processor.getDimensions();
+ }
+
+ getPixelData() {
+ return this._processor.getPixelData();
+ }
+
+ resize(newWidth, newHeight) {
+ const result = this._processor.resize(newWidth, newHeight);
+
+ // Update legacy properties
+ this.img = this._processor.img;
+ this.canvas = this._processor.canvas;
+ this.ctx = this._processor.ctx;
+
+ return result;
+ }
+
+ generatePreview(width, height) {
+ return this._processor.generatePreview(width, height);
+ }
+
+ // Cleanup method
+ cleanup() {
+ if (this._processor) {
+ this._processor.cleanup();
+ }
+ }
+ }
+
+ // WPLACE API SERVICE
+ const WPlaceService = {
+ async paintPixelInRegion(regionX, regionY, pixelX, pixelY, color) {
+ try {
+ await ensureToken();
+ if (!getTurnstileToken()) return 'token_error';
+ const payload = {
+ coords: [pixelX, pixelY],
+ colors: [color],
+ t: getTurnstileToken(),
+ fp: fpStr32,
+ };
+ var token = await createWasmToken(regionX, regionY, payload);
+ const res = await fetch(`https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/plain;charset=UTF-8', "x-pawtect-token": token },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ });
+ if (res.status === 403) {
+ console.error('ā 403 Forbidden. Turnstile token might be invalid or expired.');
+ setTurnstileToken(null);
+ createTokenPromise();
+ return 'token_error';
+ }
+ const data = await res.json();
+ return data?.painted === 1;
+ } catch (e) {
+ console.error('Paint request failed:', e);
+ return false;
+ }
+ },
+
+ async getCharges() {
+ try {
+ const res = await fetch("https://backend.wplace.live/me", {
+ credentials: "include",
+ })
+ const data = await res.json()
+ return {
+ id: data.id,
+ charges: data.charges?.count || 0,
+ max: data.charges?.max || 1,
+ cooldown: data.charges?.next || CONFIG.COOLDOWN_DEFAULT,
+ droplets: data.droplets || 0,
+ }
+ } catch (e) {
+ console.error("Failed to get charges:", e)
+ return {
+ id: null,
+ charges: 0,
+ max: 1,
+ cooldown: CONFIG.COOLDOWN_DEFAULT,
+ droplets: 0,
+ }
+ }
+ },
+
+ async fetchCheck() {
+ try {
+ const res = await fetch("https://backend.wplace.live/me", {
+ credentials: "include",
+ })
+ const data = await res.json()
+ return {
+ ID: data.id,
+ Username: data.username || data.name || null,
+ Charges: data.charges.count,
+ Max: data.charges.max,
+ Droplets: data.droplets
+ }
+ } catch (e) {
+ console.error("Failed to get ID:", e)
+ return {
+
+ }
+ }
+ }
+ };
+
+ // Desktop Notification Manager
+ const NotificationManager = {
+ pollTimer: null,
+ pollIntervalMs: 60_000,
+ icon() {
+ const link = document.querySelector("link[rel~='icon']");
+ return link?.href || location.origin + '/favicon.ico';
+ },
+ async requestPermission() {
+ if (!('Notification' in window)) {
+ Utils.showAlert(Utils.t('notificationsNotSupported'), 'warning');
+ return 'denied';
+ }
+ if (Notification.permission === 'granted') return 'granted';
+ try {
+ const perm = await Notification.requestPermission();
+ return perm;
+ } catch {
+ return Notification.permission;
+ }
+ },
+ canNotify() {
+ return (
+ state.notificationsEnabled &&
+ typeof Notification !== 'undefined' &&
+ Notification.permission === 'granted'
+ );
+ },
+ notify(title, body, tag = 'wplace-charges', force = false) {
+ if (!this.canNotify()) return false;
+ if (!force && state.notifyOnlyWhenUnfocused && document.hasFocus()) return false;
+ try {
+ new Notification(title, {
+ body,
+ tag,
+ renotify: true,
+ icon: this.icon(),
+ badge: this.icon(),
+ silent: false,
+ });
+ return true;
+ } catch {
+ // Graceful fallback
+ Utils.showAlert(body, 'info');
+ return false;
+ }
+ },
+ resetEdgeTracking() {
+ state._lastChargesBelow = state.displayCharges < state.cooldownChargeThreshold;
+ state._lastChargesNotifyAt = 0;
+ },
+ maybeNotifyChargesReached(force = false) {
+ if (!state.notificationsEnabled || !state.notifyOnChargesReached) return;
+ const reached = state.displayCharges >= state.cooldownChargeThreshold;
+ const now = Date.now();
+ const repeatMs = Math.max(1, Number(state.notificationIntervalMinutes || 5)) * 60_000;
+ if (reached) {
+ const shouldEdge = state._lastChargesBelow || force;
+ const shouldRepeat = now - (state._lastChargesNotifyAt || 0) >= repeatMs;
+ if (shouldEdge || shouldRepeat) {
+ const msg = Utils.t('chargesReadyMessage', {
+ current: state.displayCharges,
+ max: state.maxCharges,
+ threshold: state.cooldownChargeThreshold,
+ });
+ this.notify(Utils.t('chargesReadyNotification'), msg, 'wplace-notify-charges');
+ state._lastChargesNotifyAt = now;
+ }
+ state._lastChargesBelow = false;
+ } else {
+ state._lastChargesBelow = true;
+ }
+ },
+ startPolling() {
+ this.stopPolling();
+ if (!state.notificationsEnabled || !state.notifyOnChargesReached) return;
+ // lightweight background polling
+ this.pollTimer = setInterval(async () => {
+ try {
+ const { charges, cooldown, max } = await WPlaceService.getCharges();
+ state.displayCharges = Math.floor(charges);
+ state.cooldown = cooldown;
+ state.maxCharges = Math.max(1, Math.floor(max));
+ this.maybeNotifyChargesReached();
+ } catch {
+ /* ignore */
+ }
+ }, this.pollIntervalMs);
+ },
+ stopPolling() {
+ if (this.pollTimer) {
+ clearInterval(this.pollTimer);
+ this.pollTimer = null;
+ }
+ },
+ syncFromState() {
+ this.resetEdgeTracking();
+ if (state.notificationsEnabled && state.notifyOnChargesReached) this.startPolling();
+ else this.stopPolling();
+ },
+ };
+
+ // COLOR MATCHING FUNCTION - Optimized with caching
+ const colorCache = new Map();
+
+ // UI UPDATE FUNCTIONS (declared early to avoid reference errors)
+ let updateUI = () => { };
+ let updateStats = async () => { };
+ let updateDataButtons = () => { };
+
+ function updateActiveColorPalette() {
+ state.activeColorPalette = [];
+ const activeSwatches = document.querySelectorAll('.wplace-color-swatch.active');
+ if (activeSwatches) {
+ activeSwatches.forEach((swatch) => {
+ const rgbStr = swatch.getAttribute('data-rgb');
+ if (rgbStr) {
+ const rgb = rgbStr.split(',').map(Number);
+ state.activeColorPalette.push(rgb);
+ }
+ });
+ }
+ if (document.querySelector('.resize-container')?.style.display === 'block') {
+ _updateResizePreview();
+ }
+ }
+
+ function toggleAllColors(select, showingUnavailable = false) {
+ const swatches = document.querySelectorAll('.wplace-color-swatch');
+ if (swatches) {
+ swatches.forEach((swatch) => {
+ // Only toggle colors that are available or if we're showing unavailable colors
+ const isUnavailable = swatch.classList.contains('unavailable');
+ if (!isUnavailable || showingUnavailable) {
+ // Don't try to select unavailable colors
+ if (!isUnavailable) {
+ swatch.classList.toggle('active', select);
+ }
+ }
+ });
+ }
+ updateActiveColorPalette();
+ }
+
+ function unselectAllPaidColors() {
+ const swatches = document.querySelectorAll('.wplace-color-swatch');
+ if (swatches) {
+ swatches.forEach((swatch) => {
+ const colorId = parseInt(swatch.getAttribute('data-color-id'), 10);
+ if (!isNaN(colorId) && colorId >= 32) {
+ swatch.classList.toggle('active', false);
+ }
+ });
+ }
+ updateActiveColorPalette();
+ }
+
+ function initializeColorPalette(container) {
+ const colorsContainer = container.querySelector('#colors-container');
+ const showAllToggle = container.querySelector('#showAllColorsToggle');
+ if (!colorsContainer) return;
+
+ // Use already captured colors from state (captured during upload)
+ // Don't re-fetch colors here, use what was captured when user clicked upload
+ if (!state.availableColors || state.availableColors.length === 0) {
+ // If no colors have been captured yet, show message
+ colorsContainer.innerHTML = `${Utils.t(
+ 'uploadImageFirst'
+ )}
`;
+ return;
+ }
+
+ function populateColors(showUnavailable = false) {
+ colorsContainer.innerHTML = '';
+ let availableCount = 0;
+ let totalCount = 0;
+
+ // Convert COLOR_MAP to array and filter out transparent
+ const allColors = Object.values(CONFIG.COLOR_MAP).filter((color) => color.rgb !== null);
+
+ allColors.forEach((colorData) => {
+ const { id, name, rgb } = colorData;
+ const rgbKey = `${rgb.r},${rgb.g},${rgb.b}`;
+ totalCount++;
+
+ // Check if this color is available in the captured colors
+ const isAvailable = state.availableColors.some(
+ (c) => c.rgb[0] === rgb.r && c.rgb[1] === rgb.g && c.rgb[2] === rgb.b
+ );
+
+ // If not showing all colors and this color is not available, skip it
+ if (!showUnavailable && !isAvailable) {
+ return;
+ }
+
+ if (isAvailable) availableCount++;
+
+ const colorItem = Utils.createElement('div', {
+ className: 'wplace-color-item',
+ });
+ const swatch = Utils.createElement('button', {
+ className: `wplace-color-swatch ${!isAvailable ? 'unavailable' : ''}`,
+ title: `${name} (ID: ${id})${!isAvailable ? ' (Unavailable)' : ''}`,
+ 'data-rgb': rgbKey,
+ 'data-color-id': id,
+ });
+ swatch.style.backgroundColor = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
+
+ // Make unavailable colors visually distinct
+ if (!isAvailable) {
+ swatch.style.opacity = '0.4';
+ swatch.style.filter = 'grayscale(50%)';
+ swatch.disabled = true;
+ } else {
+ // Select available colors by default
+ swatch.classList.add('active');
+ }
+
+ const nameLabel = Utils.createElement(
+ 'span',
+ {
+ className: 'wplace-color-item-name',
+ style: !isAvailable ? 'color: #888; font-style: italic;' : '',
+ },
+ name + (!isAvailable ? ' (N/A)' : '')
+ );
+
+ // Only add click listener for available colors
+ if (isAvailable) {
+ swatch.addEventListener('click', () => {
+ swatch.classList.toggle('active');
+ updateActiveColorPalette();
+ });
+ }
+
+ colorItem.appendChild(swatch);
+ colorItem.appendChild(nameLabel);
+ colorsContainer.appendChild(colorItem);
+ });
+
+ updateActiveColorPalette();
+ }
+
+ // Initialize with only available colors
+ populateColors(false);
+
+ // Add toggle functionality
+ if (showAllToggle) {
+ showAllToggle.addEventListener('change', (e) => {
+ populateColors(e.target.checked);
+ });
+ }
+
+ container
+ .querySelector('#selectAllBtn')
+ ?.addEventListener('click', () => toggleAllColors(true, showAllToggle?.checked));
+ container
+ .querySelector('#unselectAllBtn')
+ ?.addEventListener('click', () => toggleAllColors(false, showAllToggle?.checked));
+ container
+ .querySelector('#unselectPaidBtn')
+ ?.addEventListener('click', () => unselectAllPaidColors());
+ }
+
+ async function handleCaptcha(allowGeneration = true) {
+ const startTime = performance.now();
+
+ // Check user's token source preference
+ if (state.tokenSource === 'manual') {
+ console.log('šÆ Manual token source selected - using pixel placement automation');
+ if (!allowGeneration) {
+ console.log('ā Token generation disabled during processing');
+ return null;
+ }
+ return await tokenManager.handleCaptchaFallback();
+ }
+
+ // Generator mode (pure) or Hybrid mode - try generator first
+ try {
+ // Use optimized token generation with automatic sitekey detection
+ const { sitekey, token: preGeneratedToken } = await Utils.obtainSitekeyAndToken();
+
+ if (!sitekey) {
+ throw new Error('No valid sitekey found');
+ }
+
+ console.log('š Generating Turnstile token for sitekey:', sitekey);
+ console.log(
+ 'š§ UA:',
+ navigator.userAgent.substring(0, 50) + '...',
+ 'Platform:',
+ navigator.platform
+ );
+
+ // Add additional checks before token generation
+ if (!window.turnstile) {
+ await Utils.loadTurnstile();
+ }
+
+ let token = null;
+
+ // ā
Reuse pre-generated token if available and valid
+ if (
+ preGeneratedToken &&
+ typeof preGeneratedToken === 'string' &&
+ preGeneratedToken.length > 20
+ ) {
+ console.log('ā»ļø Reusing pre-generated token from sitekey detection phase');
+ token = preGeneratedToken;
+ }
+ // ā
Or use globally cached token if still valid
+ else if (isTokenValid()) {
+ console.log('ā»ļø Using existing cached token (from previous operation)');
+ token = getTurnstileToken();
+ }
+ // ā
Otherwise generate a new one (only if allowed)
+ else {
+ if (!allowGeneration) {
+ console.log('ā Token expired/missing but generation disabled during processing');
+ return null;
+ }
+ console.log('š No valid pre-generated or cached token, creating new one...');
+ token = await Utils.executeTurnstile(sitekey, 'paint');
+ if (token) {
+ setTurnstileToken(token);
+ }
+ }
+
+ console.log(
+ `š Token received - Type: ${typeof token}, Value: ${token
+ ? typeof token === 'string'
+ ? token.length > 50
+ ? token.substring(0, 50) + '...'
+ : token
+ : JSON.stringify(token)
+ : 'null/undefined'
+ }, Length: ${token?.length || 0}`
+ );
+
+ // ā
Final validation
+ if (typeof token === 'string' && token.length > 20) {
+ const duration = Math.round(performance.now() - startTime);
+ console.log(`ā
Turnstile token generated successfully in ${duration}ms`);
+ return token;
+ } else {
+ throw new Error(
+ `Invalid or empty token received - Type: ${typeof token}, Value: ${JSON.stringify(
+ token
+ )}, Length: ${token?.length || 0}`
+ );
+ }
+ } catch (error) {
+ const duration = Math.round(performance.now() - startTime);
+ console.error(`ā Turnstile token generation failed after ${duration}ms:`, error);
+
+ // Fallback to manual pixel placement for hybrid mode
+ if (state.tokenSource === 'hybrid') {
+ console.log(
+ 'š Hybrid mode: Generator failed, automatically switching to manual pixel placement...'
+ );
+ const fbToken = await tokenManager.handleCaptchaFallback();
+ return fbToken;
+ } else {
+ // Pure generator mode - don't fallback, just fail
+ throw error;
+ }
+ }
+ }
+
+ async function createUI() {
+ await detectLanguage();
+
+ const existingContainer = document.getElementById('wplace-image-bot-container');
+ const existingStats = document.getElementById('wplace-stats-container');
+ const existingSettings = document.getElementById('wplace-settings-container');
+ const existingResizeContainer = document.querySelector('.resize-container');
+ const existingResizeOverlay = document.querySelector('.resize-overlay');
+
+ if (existingContainer) existingContainer.remove();
+ if (existingStats) existingStats.remove();
+ if (existingSettings) existingSettings.remove();
+ if (existingResizeContainer) existingResizeContainer.remove();
+ if (existingResizeOverlay) existingResizeOverlay.remove();
+
+ loadThemePreference();
+ await initializeTranslations();
+
+ const theme = getCurrentTheme();
+ applyTheme(); // <- new: set CSS vars and theme class before building UI
+
+ function appendLinkOnce(href, attributes = {}) {
+ // Check if a link with the same href already exists in the document head
+ const exists = Array.from(document.head.querySelectorAll('link')).some(
+ (link) => link.href === href
+ );
+ if (exists) return;
+
+ // Create a new link element
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = href;
+
+ // Add any additional attributes (e.g., data-* attributes)
+ for (const [key, value] of Object.entries(attributes)) {
+ link.setAttribute(key, value);
+ }
+
+ // Append the link element to the document head
+ document.head.appendChild(link);
+ }
+
+ appendLinkOnce('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css');
+
+ if (theme.fontFamily.includes('Press Start 2P')) {
+ appendLinkOnce('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
+ }
+
+ // Load auto-image-styles.css - prioritize extension local resources
+ console.group('%cšØ Loading Auto-Image Styles', 'color: #8b5cf6; font-weight: bold;');
+
+ let stylesLoaded = false;
+
+ // First try: Check if extension has loaded local CSS resources
+ if (window.AUTOBOT_THEMES && window.AUTOBOT_THEMES['auto-image-styles.css']) {
+ console.log('%cš Found auto-image-styles.css in extension local resources!', 'color: #10b981; font-weight: bold;');
+
+ // Check if it's already injected by the extension
+ const existingStyle = document.getElementById('autobot-auto-image-styles');
+ if (existingStyle) {
+ console.log('%cā
Styles already injected by extension - using local version', 'color: #10b981; font-weight: bold;');
+ console.log(' š Source: Extension local file (already in DOM)');
+ console.log(' š Element ID: autobot-auto-image-styles');
+ stylesLoaded = true;
+ } else {
+ // Inject it ourselves
+ const styleElement = document.createElement('style');
+ styleElement.id = 'autobot-auto-image-styles-script';
+ styleElement.setAttribute('data-wplace-theme', 'true');
+ styleElement.textContent = window.AUTOBOT_THEMES['auto-image-styles.css'];
+ document.head.appendChild(styleElement);
+
+ console.log('%cā
Injected auto-image-styles.css from extension local resources', 'color: #10b981; font-weight: bold;');
+ console.log(' š Source: Extension local file');
+ console.log(' š Size: ' + window.AUTOBOT_THEMES['auto-image-styles.css'].length + ' characters');
+ console.log(' š Element ID: autobot-auto-image-styles-script');
+ stylesLoaded = true;
+ }
+ } else {
+ console.log('%cš auto-image-styles.css not found in extension resources', 'color: #f59e0b;');
+ console.log(' š window.AUTOBOT_THEMES:', typeof window.AUTOBOT_THEMES);
+ if (window.AUTOBOT_THEMES) {
+ console.log(' š Available themes:', Object.keys(window.AUTOBOT_THEMES));
+ }
+ }
+
+ // Fallback: Load from CDN if local resources not available
+ if (!stylesLoaded) {
+ console.log('%cš Falling back to CDN loading...', 'color: #8b5cf6;');
+ appendLinkOnce(
+ 'https://wplace-autobot.github.io/WPlace-AutoBOT/main/auto-image-styles.css',
+ { 'data-wplace-theme': 'true' }
+ );
+ console.log('%cš Loaded auto-image-styles.css from CDN (fallback)', 'color: #f59e0b; font-weight: bold;');
+ console.log(' š Source: CDN (wplace-autobot.github.io)');
+ console.log(' š URL: https://wplace-autobot.github.io/WPlace-AutoBOT/main/auto-image-styles.css');
+ console.log(' ā ļø Performance: Network request required');
+ }
+
+ console.groupEnd();
+
+ // Ensure default theme is loaded from extension resources
+ if (window.applyTheme && typeof window.applyTheme === 'function') {
+ console.group('%cšØ Loading Default Theme from Extension', 'color: #8b5cf6; font-weight: bold;');
+
+ // Determine the current theme file name
+ let defaultTheme = 'classic'; // fallback
+ if (CONFIG.currentTheme === 'Neon Retro') {
+ defaultTheme = 'neon';
+ } else if (CONFIG.currentTheme === 'Neon Retro Cyan') {
+ defaultTheme = 'neon-cyan';
+ } else if (CONFIG.currentTheme === 'Neon Retro Light') {
+ defaultTheme = 'neon-light';
+ } else if (CONFIG.currentTheme === 'Classic Light') {
+ defaultTheme = 'classic-light';
+ } else if (CONFIG.currentTheme === 'Acrylic') {
+ defaultTheme = 'acrylic';
+ }
+
+ console.log(`%cšÆ Loading theme: ${defaultTheme} (${CONFIG.currentTheme})`, 'color: #8b5cf6;');
+ const success = window.applyTheme(defaultTheme);
+
+ if (success) {
+ console.log(`%cā
Default theme loaded from extension local resources`, 'color: #10b981; font-weight: bold;');
+ console.log(` š Source: Extension local file (themes/${defaultTheme}.css)`);
+ console.log(` š Performance: Instant load (no CDN request)`);
+ console.log(` šØ Theme: ${CONFIG.currentTheme}`);
+ } else {
+ console.warn(`%cā ļø Failed to load theme ${defaultTheme} from extension`, 'color: #f59e0b;');
+ console.log(` š Note: Theme CSS classes will still work`);
+ }
+
+ console.groupEnd();
+ } else {
+ console.warn(`%cā ļø Extension applyTheme() function not available`, 'color: #f59e0b; font-weight: bold;');
+ console.log(` š Themes will be limited to CSS classes only`);
+ }
+
+ const container = document.createElement('div');
+ container.id = 'wplace-image-bot-container';
+ container.innerHTML = `
+
+
+
+
+
+ ${Utils.t('initMessage')}
+
+
+
+
+
+
+
š¼ļø Image Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
š® Painting Control
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ā±ļø ${Utils.t('cooldownSettings')}
+
+
+
+
+
+
+
+
+
š¾ Data Management
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const statsContainer = document.createElement('div');
+ statsContainer.id = 'wplace-stats-container';
+ statsContainer.style.display = 'none';
+ statsContainer.innerHTML = `
+
+
+
+
+
+
${Utils.t('initMessage')}
+
+
+
+
+
+
+
+
+ Account Swapper
+
+
+
+
+
+
+
+
+
+ Auto Buy Charges
+
+
+
+
+
+
+
+
+ All Accounts
+
+
+
+
Click the icon to load accounts.
+
+
+
+ `;
+
+ function initPillSelector() {
+ const buttons = statsContainer.querySelectorAll(".pill-btn");
+ const highlight = statsContainer.querySelector(".pill-highlight");
+
+ buttons.forEach((btn, index) => {
+ btn.addEventListener("click", () => {
+ highlight.style.transform = `translateX(${index * 100}%)`;
+
+ buttons.forEach((b) => b.classList.remove("active"));
+ btn.classList.add("active");
+
+ CONFIG.autoBuy = btn.dataset.mode;
+ if (CONFIG.autoBuy === 'none') {
+ console.log("AutoBuy disabled");
+ CONFIG.autoBuyToggle = false;
+ }
+ else {
+ CONFIG.autoBuyToggle = true;
+ console.log("AutoBuy enabled");
+ }
+ console.log("AutoBuy mode set to:", CONFIG.autoBuy);
+ });
+ });
+ }
+
+ initPillSelector();
+
+ // Modern Settings Container with Theme Support
+ // Use the theme variable already declared at the top of createUI function
+ const settingsContainer = document.createElement('div');
+ settingsContainer.id = 'wplace-settings-container';
+
+ // Apply theme-based styling
+ const themeBackground = theme.primary
+ ? `linear-gradient(135deg, ${theme.primary} 0%, ${theme.secondary || theme.primary} 100%)`
+ : `linear-gradient(135deg, #667eea 0%, #764ba2 100%)`;
+
+ settingsContainer.className = 'wplace-settings-container-base';
+ // Apply theme-specific background
+ settingsContainer.style.background = themeBackground;
+ settingsContainer.style.cssText += `
+ min-width: 420px;
+ max-width: 480px;
+ z-index: 99999;
+ color: ${theme.text || 'white'};
+ font-family: ${theme.fontFamily || "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif"};
+ box-shadow: ${theme.boxShadow || '0 20px 40px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.1)'
+ };
+ backdrop-filter: ${theme.backdropFilter || 'blur(10px)'};
+ overflow: hidden;
+ animation: settings-slide-in 0.4s ease-out;
+ ${theme.animations?.glow
+ ? `
+ box-shadow: ${theme.boxShadow || '0 20px 40px rgba(0,0,0,0.3)'},
+ 0 0 30px ${theme.highlight || theme.neon || '#00ffff'};
+ `
+ : ''
+ }
+ `;
+
+ // noinspection CssInvalidFunction
+ settingsContainer.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ Generator mode creates tokens automatically. Hybrid mode falls back to manual when generator fails. Manual mode only uses pixel placement.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${CONFIG.PAINTING_SPEED.MIN}
+ ${CONFIG.PAINTING_SPEED.MAX}
+
+
+
+
+
+
+
+ š² Random batch size between min and max values
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ š§± Block dimensions for block-based generation modes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const resizeContainer = document.createElement('div');
+ resizeContainer.className = 'resize-container';
+ resizeContainer.innerHTML = `
+ ${Utils.t('resizeImage')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
100%
+
+ Drag to pan ⢠Pinch to zoom ⢠Doubleātap to zoom
+
+
+
+
+
+
+
+
+
+ Color Palette
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Advanced Color Matching
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const resizeOverlay = document.createElement('div');
+ resizeOverlay.className = 'resize-overlay';
+
+ document.body.appendChild(container);
+ document.body.appendChild(resizeOverlay);
+ document.body.appendChild(resizeContainer);
+ document.body.appendChild(statsContainer);
+ document.body.appendChild(settingsContainer);
+
+ // Show the main container after all elements are appended
+ container.style.display = 'block';
+
+ const uploadBtn = container.querySelector('#uploadBtn');
+ const resizeBtn = container.querySelector('#resizeBtn');
+ const selectPosBtn = container.querySelector('#selectPosBtn');
+ const startBtn = container.querySelector('#startBtn');
+ const stopBtn = container.querySelector('#stopBtn');
+ const saveBtn = container.querySelector('#saveBtn');
+ const loadBtn = container.querySelector('#loadBtn');
+ const saveToFileBtn = container.querySelector('#saveToFileBtn');
+ const loadFromFileBtn = container.querySelector('#loadFromFileBtn');
+
+ container.querySelectorAll('.wplace-section-title').forEach((title) => {
+ // Add a right-side arrow if it doesn't exist
+ if (!title.querySelector('i.arrow')) {
+ const arrow = document.createElement('i');
+ arrow.className = 'fas fa-chevron-down arrow'; // FontAwesome down arrow
+ title.appendChild(arrow);
+ }
+
+ // Click event to toggle collapse/expand of the section
+ title.addEventListener('click', () => {
+ const section = title.parentElement;
+ section.classList.toggle('collapsed');
+ });
+ });
+
+ // Disable load/upload buttons until initial setup is complete (startup only)
+ if (loadBtn) {
+ loadBtn.disabled = !state.initialSetupComplete;
+ loadBtn.title = state.initialSetupComplete
+ ? ''
+ : 'š Waiting for initial setup to complete...';
+ }
+ if (loadFromFileBtn) {
+ loadFromFileBtn.disabled = !state.initialSetupComplete;
+ loadFromFileBtn.title = state.initialSetupComplete
+ ? ''
+ : 'š Waiting for initial setup to complete...';
+ }
+ if (uploadBtn) {
+ uploadBtn.disabled = !state.initialSetupComplete;
+ uploadBtn.title = state.initialSetupComplete
+ ? ''
+ : 'š Waiting for initial setup to complete...';
+ }
+
+ const minimizeBtn = container.querySelector('#minimizeBtn');
+ const compactBtn = container.querySelector('#compactBtn');
+ const statsBtn = container.querySelector('#statsBtn');
+ const toggleOverlayBtn = container.querySelector('#toggleOverlayBtn');
+ const statusText = container.querySelector('#statusText');
+ const progressBar = container.querySelector('#progressBar');
+ const statsArea = statsContainer.querySelector('#statsArea');
+ const content = container.querySelector('.wplace-content');
+ const closeStatsBtn = statsContainer.querySelector('#closeStatsBtn');
+ const refreshChargesBtn = statsContainer.querySelector('#refreshChargesBtn');
+ const cooldownSlider = container.querySelector('#cooldownSlider');
+ const cooldownInput = container.querySelector('#cooldownInput');
+ const cooldownDecrease = container.querySelector('#cooldownDecrease');
+ const cooldownIncrease = container.querySelector('#cooldownIncrease');
+ const cooldownValue = container.querySelector('#cooldownValue');
+
+ if (!uploadBtn || !selectPosBtn || !startBtn || !stopBtn) {
+ console.error('Some UI elements not found:', {
+ uploadBtn: !!uploadBtn,
+ selectPosBtn: !!selectPosBtn,
+ startBtn: !!startBtn,
+ stopBtn: !!stopBtn,
+ });
+ }
+
+ if (!statsContainer || !statsArea || !closeStatsBtn) {
+ // Note: base CSS now aligns with this layout: main panel at left:20px (width 280), stats at left:330px.
+ }
+
+ const header = container.querySelector('.wplace-header');
+
+ makeDraggable(container);
+
+ function makeDraggable(element) {
+ let pos1 = 0,
+ pos2 = 0,
+ pos3 = 0,
+ pos4 = 0;
+ let isDragging = false;
+ const header =
+ element.querySelector('.wplace-header') || element.querySelector('.wplace-settings-header');
+
+ if (!header) {
+ console.warn('No draggable header found for element:', element);
+ return;
+ }
+
+ header.onmousedown = dragMouseDown;
+
+ function dragMouseDown(e) {
+ if (e.target.closest('.wplace-header-btn') || e.target.closest('button')) return;
+
+ e.preventDefault();
+ isDragging = true;
+
+ const rect = element.getBoundingClientRect();
+
+ element.style.transform = 'none';
+ element.style.top = rect.top + 'px';
+ element.style.left = rect.left + 'px';
+
+ pos3 = e.clientX;
+ pos4 = e.clientY;
+ element.classList.add('wplace-dragging');
+ document.onmouseup = closeDragElement;
+ document.onmousemove = elementDrag;
+
+ document.body.style.userSelect = 'none';
+ }
+
+ function elementDrag(e) {
+ if (!isDragging) return;
+
+ e.preventDefault();
+ pos1 = pos3 - e.clientX;
+ pos2 = pos4 - e.clientY;
+ pos3 = e.clientX;
+ pos4 = e.clientY;
+
+ let newTop = element.offsetTop - pos2;
+ let newLeft = element.offsetLeft - pos1;
+
+ const rect = element.getBoundingClientRect();
+ const maxTop = window.innerHeight - rect.height;
+ const maxLeft = window.innerWidth - rect.width;
+
+ newTop = Math.max(0, Math.min(newTop, maxTop));
+ newLeft = Math.max(0, Math.min(newLeft, maxLeft));
+
+ element.style.top = newTop + 'px';
+ element.style.left = newLeft + 'px';
+ }
+
+ function closeDragElement() {
+ isDragging = false;
+ element.classList.remove('wplace-dragging');
+ document.onmouseup = null;
+ document.onmousemove = null;
+ document.body.style.userSelect = '';
+ }
+ }
+
+ makeDraggable(statsContainer);
+ makeDraggable(container);
+
+ if (statsBtn && closeStatsBtn) {
+ statsBtn.addEventListener('click', () => {
+ const isVisible = statsContainer.style.display !== 'none';
+ if (isVisible) {
+ statsContainer.style.display = 'none';
+ statsBtn.innerHTML = '';
+ statsBtn.title = Utils.t('showStats');
+ } else {
+ statsContainer.style.display = 'block';
+ statsBtn.innerHTML = '';
+ statsBtn.title = Utils.t('hideStats');
+ }
+ });
+
+ closeStatsBtn.addEventListener('click', () => {
+ statsContainer.style.display = 'none';
+ statsBtn.innerHTML = '';
+ statsBtn.title = Utils.t('showStats');
+ });
+
+ if (refreshChargesBtn) {
+ refreshChargesBtn.addEventListener('click', async () => {
+ refreshChargesBtn.innerHTML = '';
+ refreshChargesBtn.disabled = true;
+
+ try {
+ await updateStats();
+ await updateCurrentAccountInList();
+ } catch (error) {
+ console.error('Error refreshing charges:', error);
+ } finally {
+ refreshChargesBtn.innerHTML = '';
+ refreshChargesBtn.disabled = false;
+ }
+ });
+ }
+
+ // Account-related event handlers
+ const autoSwapToggle = statsContainer.querySelector('#autoSwapToggle');
+ const refreshAllAccountsBtn = statsContainer.querySelector('#refreshAllAccountsBtn');
+
+ if (autoSwapToggle) {
+ autoSwapToggle.checked = CONFIG.autoSwap;
+ autoSwapToggle.addEventListener('change', (e) => {
+ CONFIG.autoSwap = e.target.checked;
+ console.log(`š Auto-swap ${CONFIG.autoSwap ? 'enabled' : 'disabled'}`);
+
+ // Handle autoBuy toggle dependency
+ const autoBuyToggle = statsContainer.querySelector('#autoBuyToggle');
+ if (autoBuyToggle) {
+ if (!CONFIG.autoSwap) {
+ // If autoSwap is disabled, disable and uncheck autoBuy
+ autoBuyToggle.disabled = true;
+ autoBuyToggle.checked = false;
+ CONFIG.autoBuyToggle = false;
+ console.log(`š° Auto-buy disabled (requires auto-swap)`);
+ } else {
+ // If autoSwap is enabled, enable autoBuy toggle
+ autoBuyToggle.disabled = false;
+ }
+ }
+ });
+ }
+
+ // Auto Buy toggle logic
+ const autoBuyToggle = statsContainer.querySelector('#autoBuyToggle');
+ if (autoBuyToggle) {
+ autoBuyToggle.checked = CONFIG.autoBuyToggle;
+ autoBuyToggle.disabled = !CONFIG.autoSwap; // Disable if autoSwap is off
+
+ autoBuyToggle.addEventListener('change', (e) => {
+ CONFIG.autoBuyToggle = e.target.checked;
+ console.log(`š° Auto-buy ${CONFIG.autoBuyToggle ? 'enabled' : 'disabled'}`);
+ });
+ }
+
+ if (refreshAllAccountsBtn) {
+ refreshAllAccountsBtn.addEventListener('click', fetchAllAccountDetails);
+ }
+ }
+ if (statsContainer && statsBtn) {
+ // Stats container starts visible by default - user clicks button to hide
+ statsContainer.style.display = 'block';
+ statsBtn.innerHTML = '';
+ statsBtn.title = Utils.t('hideStats');
+ }
+
+ const settingsBtn = container.querySelector('#settingsBtn');
+ const closeSettingsBtn = settingsContainer.querySelector('#closeSettingsBtn');
+ const applySettingsBtn = settingsContainer.querySelector('#applySettingsBtn');
+
+ if (settingsBtn && closeSettingsBtn && applySettingsBtn) {
+ settingsBtn.addEventListener('click', () => {
+ const isVisible = settingsContainer.classList.contains('show');
+ if (isVisible) {
+ settingsContainer.style.animation = 'settings-fade-out 0.3s ease-out forwards';
+ settingsContainer.classList.remove('show');
+ setTimeout(() => {
+ settingsContainer.style.animation = '';
+ }, 300);
+ } else {
+ settingsContainer.style.top = '50%';
+ settingsContainer.style.left = '50%';
+ settingsContainer.style.transform = 'translate(-50%, -50%)';
+ settingsContainer.classList.add('show');
+ settingsContainer.style.animation = 'settings-slide-in 0.4s ease-out';
+ }
+ });
+
+ closeSettingsBtn.addEventListener('click', () => {
+ settingsContainer.style.animation = 'settings-fade-out 0.3s ease-out forwards';
+ settingsContainer.classList.remove('show');
+ setTimeout(() => {
+ settingsContainer.style.animation = '';
+ settingsContainer.style.top = '50%';
+ settingsContainer.style.left = '50%';
+ settingsContainer.style.transform = 'translate(-50%, -50%)';
+ }, 300);
+ });
+
+ applySettingsBtn.addEventListener('click', () => {
+ // Sync advanced settings before save
+ const colorAlgorithmSelect = document.getElementById('colorAlgorithmSelect');
+ if (colorAlgorithmSelect) state.colorMatchingAlgorithm = colorAlgorithmSelect.value;
+ const enableChromaPenaltyToggle = document.getElementById('enableChromaPenaltyToggle');
+ if (enableChromaPenaltyToggle)
+ state.enableChromaPenalty = enableChromaPenaltyToggle.checked;
+ const chromaPenaltyWeightSlider = document.getElementById('chromaPenaltyWeightSlider');
+ if (chromaPenaltyWeightSlider)
+ state.chromaPenaltyWeight = parseFloat(chromaPenaltyWeightSlider.value) || 0.15;
+ const transparencyThresholdInput = document.getElementById('transparencyThresholdInput');
+ if (transparencyThresholdInput) {
+ const v = parseInt(transparencyThresholdInput.value, 10);
+ if (!isNaN(v) && v >= 0 && v <= 255) state.customTransparencyThreshold = v;
+ }
+ const whiteThresholdInput = document.getElementById('whiteThresholdInput');
+ if (whiteThresholdInput) {
+ const v = parseInt(whiteThresholdInput.value, 10);
+ if (!isNaN(v) && v >= 200 && v <= 255) state.customWhiteThreshold = v;
+ }
+ // Update functional thresholds
+ CONFIG.TRANSPARENCY_THRESHOLD = state.customTransparencyThreshold;
+ CONFIG.WHITE_THRESHOLD = state.customWhiteThreshold;
+ // Notifications
+ const notifEnabledToggle = document.getElementById('notifEnabledToggle');
+ const notifOnChargesToggle = document.getElementById('notifOnChargesToggle');
+ const notifOnlyUnfocusedToggle = document.getElementById('notifOnlyUnfocusedToggle');
+ const notifIntervalInput = document.getElementById('notifIntervalInput');
+ if (notifEnabledToggle) state.notificationsEnabled = !!notifEnabledToggle.checked;
+ if (notifOnChargesToggle) state.notifyOnChargesReached = !!notifOnChargesToggle.checked;
+ if (notifOnlyUnfocusedToggle)
+ state.notifyOnlyWhenUnfocused = !!notifOnlyUnfocusedToggle.checked;
+ if (notifIntervalInput) {
+ const v = parseInt(notifIntervalInput.value, 10);
+ if (!isNaN(v) && v >= 1 && v <= 60) state.notificationIntervalMinutes = v;
+ }
+ saveBotSettings();
+ Utils.showAlert(Utils.t('settingsSaved'), 'success');
+ closeSettingsBtn.click();
+ NotificationManager.syncFromState();
+ });
+
+ makeDraggable(settingsContainer);
+
+ const tokenSourceSelect = settingsContainer.querySelector('#tokenSourceSelect');
+ if (tokenSourceSelect) {
+ tokenSourceSelect.addEventListener('change', (e) => {
+ state.tokenSource = e.target.value;
+ saveBotSettings();
+ console.log(`š Token source changed to: ${state.tokenSource}`);
+ const sourceNames = {
+ generator: 'Automatic Generator',
+ hybrid: 'Generator + Auto Fallback',
+ manual: 'Manual Pixel Placement',
+ };
+ Utils.showAlert(
+ Utils.t('tokenSourceSet', { source: sourceNames[state.tokenSource] }),
+ 'success'
+ );
+ });
+ }
+
+ // Painting order controls
+ const paintingOrderSelect = settingsContainer.querySelector('#paintingOrderSelect');
+ if (paintingOrderSelect) {
+ paintingOrderSelect.addEventListener('change', (e) => {
+ state.paintingOrder = e.target.value;
+ saveBotSettings();
+ console.log(`šØ Painting order changed to: ${state.paintingOrder}`);
+ const orderNames = {
+ sequential: Utils.t('paintingOrderSequential'),
+ 'color-by-color': Utils.t('paintingOrderColorByColor'),
+ };
+ Utils.showAlert(
+ `Painting order set to: ${orderNames[state.paintingOrder]}`,
+ 'success'
+ );
+ });
+ }
+
+ // Batch mode controls
+ const batchModeSelect = settingsContainer.querySelector('#batchModeSelect');
+ const normalBatchControls = settingsContainer.querySelector('#normalBatchControls');
+ const randomBatchControls = settingsContainer.querySelector('#randomBatchControls');
+ const randomBatchMin = settingsContainer.querySelector('#randomBatchMin');
+ const randomBatchMax = settingsContainer.querySelector('#randomBatchMax');
+
+ if (batchModeSelect) {
+ batchModeSelect.addEventListener('change', (e) => {
+ state.batchMode = e.target.value;
+
+ // Switch between normal and random controls
+ if (normalBatchControls && randomBatchControls) {
+ if (e.target.value === 'random') {
+ normalBatchControls.style.display = 'none';
+ randomBatchControls.style.display = 'block';
+ } else {
+ normalBatchControls.style.display = 'block';
+ randomBatchControls.style.display = 'none';
+ }
+ }
+
+ saveBotSettings();
+ console.log(`š¦ Batch mode changed to: ${state.batchMode}`);
+ Utils.showAlert(
+ Utils.t('batchModeSet', {
+ mode:
+ state.batchMode === 'random' ? Utils.t('randomRange') : Utils.t('normalFixedSize'),
+ }),
+ 'success'
+ );
+ });
+ }
+
+ if (randomBatchMin) {
+ randomBatchMin.addEventListener('input', (e) => {
+ const min = parseInt(e.target.value);
+ if (min >= 1 && min <= 1000) {
+ state.randomBatchMin = min;
+ // Ensure min doesn't exceed max
+ if (randomBatchMax && min > state.randomBatchMax) {
+ state.randomBatchMax = min;
+ randomBatchMax.value = min;
+ }
+ saveBotSettings();
+ }
+ });
+ }
+
+ if (randomBatchMax) {
+ randomBatchMax.addEventListener('input', (e) => {
+ const max = parseInt(e.target.value);
+ if (max >= 1 && max <= 1000) {
+ state.randomBatchMax = max;
+ // Ensure max doesn't go below min
+ if (randomBatchMin && max < state.randomBatchMin) {
+ state.randomBatchMin = max;
+ randomBatchMin.value = max;
+ }
+ saveBotSettings();
+ }
+ });
+ }
+
+ const languageSelect = settingsContainer.querySelector('#languageSelect');
+ if (languageSelect) {
+ languageSelect.addEventListener('change', async (e) => {
+ const newLanguage = e.target.value;
+ state.language = newLanguage;
+ localStorage.setItem('wplace_language', newLanguage);
+
+ // Load the new language translations
+ await loadTranslations(newLanguage);
+
+ setTimeout(() => {
+ settingsContainer.style.display = 'none';
+ createUI();
+ }, 100);
+ });
+ }
+
+ const themeSelect = settingsContainer.querySelector('#themeSelect');
+ if (themeSelect) {
+ themeSelect.addEventListener('change', (e) => {
+ const newTheme = e.target.value;
+ switchTheme(newTheme);
+ });
+ }
+
+ const overlayOpacitySlider = settingsContainer.querySelector('#overlayOpacitySlider');
+ const overlayOpacityValue = settingsContainer.querySelector('#overlayOpacityValue');
+ const enableBlueMarbleToggle = settingsContainer.querySelector('#enableBlueMarbleToggle');
+ const settingsPaintWhiteToggle = settingsContainer.querySelector('#settingsPaintWhiteToggle');
+ const settingsPaintTransparentToggle = settingsContainer.querySelector(
+ '#settingsPaintTransparentToggle'
+ );
+
+ if (overlayOpacitySlider && overlayOpacityValue) {
+ const updateOpacity = (newValue) => {
+ const opacity = parseFloat(newValue);
+ state.overlayOpacity = opacity;
+ overlayOpacitySlider.value = opacity;
+ overlayOpacityValue.textContent = `${Math.round(opacity * 100)}%`;
+ };
+
+ overlayOpacitySlider.addEventListener('input', (e) => {
+ updateOpacity(e.target.value);
+ });
+
+ // Add scroll-to-adjust for overlay opacity slider
+ Utils.createScrollToAdjust(overlayOpacitySlider, updateOpacity, 0, 1, 0.05);
+ }
+
+ if (settingsPaintWhiteToggle) {
+ settingsPaintWhiteToggle.checked = state.paintWhitePixels;
+ settingsPaintWhiteToggle.addEventListener('change', (e) => {
+ state.paintWhitePixels = e.target.checked;
+ saveBotSettings();
+ console.log(`šØ Paint white pixels: ${state.paintWhitePixels ? 'ON' : 'OFF'}`);
+ const statusText = state.paintWhitePixels
+ ? 'White pixels in the template will be painted'
+ : 'White pixels will be skipped';
+ Utils.showAlert(statusText, 'success');
+ });
+ }
+
+ if (settingsPaintTransparentToggle) {
+ settingsPaintTransparentToggle.checked = state.paintTransparentPixels;
+ settingsPaintTransparentToggle.addEventListener('change', (e) => {
+ state.paintTransparentPixels = e.target.checked;
+ saveBotSettings();
+ console.log(
+ `šØ Paint transparent pixels: ${state.paintTransparentPixels ? 'ON' : 'OFF'}`
+ );
+ const statusText = state.paintTransparentPixels
+ ? 'Transparent pixels in the template will be painted with the closest available color'
+ : 'Transparent pixels will be skipped';
+ Utils.showAlert(statusText, 'success');
+ });
+ }
+
+ // Speed controls - both slider and input
+ const speedSlider = settingsContainer.querySelector('#speedSlider');
+ const speedInput = settingsContainer.querySelector('#speedInput');
+ const speedDecrease = settingsContainer.querySelector('#speedDecrease');
+ const speedIncrease = settingsContainer.querySelector('#speedIncrease');
+ const speedValue = settingsContainer.querySelector('#speedValue');
+
+ if (speedSlider && speedInput && speedValue && speedDecrease && speedIncrease) {
+ const updateSpeed = (newValue) => {
+ const speed = Math.max(CONFIG.PAINTING_SPEED.MIN, Math.min(CONFIG.PAINTING_SPEED.MAX, parseInt(newValue)));
+ state.paintingSpeed = speed;
+
+ // Update both controls (value shows in input, label shows unit only)
+ speedSlider.value = speed;
+ speedInput.value = speed;
+ speedValue.textContent = `pixels`;
+
+ saveBotSettings();
+ };
+
+ // Slider event listener
+ speedSlider.addEventListener('input', (e) => {
+ updateSpeed(e.target.value);
+ });
+
+ // Number input event listener
+ speedInput.addEventListener('input', (e) => {
+ updateSpeed(e.target.value);
+ });
+
+ // Decrease button
+ speedDecrease.addEventListener('click', () => {
+ updateSpeed(parseInt(speedInput.value) - 1);
+ });
+
+ // Increase button
+ speedIncrease.addEventListener('click', () => {
+ updateSpeed(parseInt(speedInput.value) + 1);
+ });
+
+ // Add scroll-to-adjust for speed slider
+ Utils.createScrollToAdjust(speedSlider, updateSpeed, CONFIG.PAINTING_SPEED.MIN, CONFIG.PAINTING_SPEED.MAX, 1);
+ }
+
+ if (enableBlueMarbleToggle) {
+ enableBlueMarbleToggle.addEventListener('click', async () => {
+ state.blueMarbleEnabled = enableBlueMarbleToggle.checked;
+ if (state.imageLoaded && overlayManager.imageBitmap) {
+ Utils.showAlert(Utils.t('reprocessingOverlay'), 'info');
+ await overlayManager.processImageIntoChunks();
+ Utils.showAlert(Utils.t('overlayUpdated'), 'success');
+ }
+ });
+ }
+
+ // Speed control toggle
+ const enableSpeedToggle = settingsContainer.querySelector('#enableSpeedToggle');
+ if (enableSpeedToggle) {
+ enableSpeedToggle.addEventListener('change', (e) => {
+ CONFIG.PAINTING_SPEED_ENABLED = e.target.checked;
+ saveBotSettings();
+ console.log(`ā” Batch speed control: ${CONFIG.PAINTING_SPEED_ENABLED ? 'ON' : 'OFF'}`);
+ const statusText = CONFIG.PAINTING_SPEED_ENABLED
+ ? 'Batch speed control enabled - will use configured batch size'
+ : 'Batch speed control disabled - will use all available charges immediately';
+ Utils.showAlert(statusText, 'success');
+ });
+ }
+
+ // (Advanced color listeners moved outside to work with resize dialog)
+ // (Advanced color listeners moved outside to work with resize dialog)
+ // Notifications listeners
+ const notifPermBtn = settingsContainer.querySelector('#notifRequestPermBtn');
+ const notifTestBtn = settingsContainer.querySelector('#notifTestBtn');
+ if (notifPermBtn) {
+ notifPermBtn.addEventListener('click', async () => {
+ const perm = await NotificationManager.requestPermission();
+ if (perm === 'granted') Utils.showAlert(Utils.t('notificationsEnabled'), 'success');
+ else Utils.showAlert(Utils.t('notificationsPermissionDenied'), 'warning');
+ });
+ }
+ if (notifTestBtn) {
+ notifTestBtn.addEventListener('click', () => {
+ NotificationManager.notify(
+ Utils.t('testNotificationTitle'),
+ Utils.t('testNotificationMessage'),
+ 'wplace-notify-test',
+ true
+ );
+ });
+ }
+ }
+
+ const widthSlider = resizeContainer.querySelector('#widthSlider');
+ const heightSlider = resizeContainer.querySelector('#heightSlider');
+ const widthValue = resizeContainer.querySelector('#widthValue');
+ const heightValue = resizeContainer.querySelector('#heightValue');
+ const keepAspect = resizeContainer.querySelector('#keepAspect');
+ const paintWhiteToggle = resizeContainer.querySelector('#paintWhiteToggle');
+ const paintTransparentToggle = resizeContainer.querySelector('#paintTransparentToggle');
+ const zoomSlider = resizeContainer.querySelector('#zoomSlider');
+ const zoomValue = resizeContainer.querySelector('#zoomValue');
+ const zoomInBtn = resizeContainer.querySelector('#zoomInBtn');
+ const zoomOutBtn = resizeContainer.querySelector('#zoomOutBtn');
+ const zoomFitBtn = resizeContainer.querySelector('#zoomFitBtn');
+ const zoomActualBtn = resizeContainer.querySelector('#zoomActualBtn');
+ const panModeBtn = resizeContainer.querySelector('#panModeBtn');
+ const panStage = resizeContainer.querySelector('#resizePanStage');
+ const canvasStack = resizeContainer.querySelector('#resizeCanvasStack');
+ const baseCanvas = resizeContainer.querySelector('#resizeCanvas');
+ const maskCanvas = resizeContainer.querySelector('#maskCanvas');
+ const baseCtx = baseCanvas.getContext('2d');
+ const maskCtx = maskCanvas.getContext('2d');
+ const confirmResize = resizeContainer.querySelector('#confirmResize');
+ const cancelResize = resizeContainer.querySelector('#cancelResize');
+ const editImageBtn = resizeContainer.querySelector('#editImageBtn');
+ const downloadPreviewBtn = resizeContainer.querySelector('#downloadPreviewBtn');
+ const clearIgnoredBtn = resizeContainer.querySelector('#clearIgnoredBtn');
+
+ // Coordinate generation controls with smart visibility
+ const coordinateModeSelect = settingsContainer.querySelector('#coordinateModeSelect');
+ const coordinateDirectionSelect = settingsContainer.querySelector('#coordinateDirectionSelect');
+ const coordinateSnakeToggle = settingsContainer.querySelector('#coordinateSnakeToggle');
+ const directionControls = settingsContainer.querySelector('#directionControls');
+ const snakeControls = settingsContainer.querySelector('#snakeControls');
+ const blockControls = settingsContainer.querySelector('#blockControls');
+ const blockWidthInput = settingsContainer.querySelector('#blockWidthInput');
+ const blockHeightInput = settingsContainer.querySelector('#blockHeightInput');
+ const paintUnavailablePixelsToggle = settingsContainer.querySelector(
+ '#paintUnavailablePixelsToggle'
+ );
+
+ if (paintUnavailablePixelsToggle) {
+ paintUnavailablePixelsToggle.checked = state.paintUnavailablePixels;
+ paintUnavailablePixelsToggle.addEventListener('change', (e) => {
+ state.paintUnavailablePixels = e.target.checked;
+ saveBotSettings();
+ console.log(`šØ Paint unavailable colors: ${state.paintUnavailablePixels ? 'ON' : 'OFF'}`);
+ const statusText = state.paintUnavailablePixels
+ ? 'Unavailable template colors will be painted with the closest available color'
+ : 'Unavailable template colors will be skipped';
+ Utils.showAlert(statusText, 'success');
+ });
+ }
+ if (coordinateModeSelect) {
+ coordinateModeSelect.value = state.coordinateMode;
+ coordinateModeSelect.addEventListener('change', (e) => {
+ state.coordinateMode = e.target.value;
+ Utils.updateCoordinateUI({
+ mode: state.coordinateMode,
+ directionControls,
+ snakeControls,
+ blockControls,
+ });
+ saveBotSettings();
+ console.log(`š Coordinate mode changed to: ${state.coordinateMode}`);
+ Utils.showAlert(`Coordinate mode set to: ${state.coordinateMode}`, 'success');
+ });
+ }
+
+ if (coordinateDirectionSelect) {
+ coordinateDirectionSelect.value = state.coordinateDirection;
+ coordinateDirectionSelect.addEventListener('change', (e) => {
+ state.coordinateDirection = e.target.value;
+ saveBotSettings();
+ console.log(`š§ Coordinate direction changed to: ${state.coordinateDirection}`);
+ Utils.showAlert(`Coordinate direction set to: ${state.coordinateDirection}`, 'success');
+ });
+ }
+
+ if (coordinateSnakeToggle) {
+ coordinateSnakeToggle.checked = state.coordinateSnake;
+ coordinateSnakeToggle.addEventListener('change', (e) => {
+ state.coordinateSnake = e.target.checked;
+ saveBotSettings();
+ console.log(`š Snake pattern ${state.coordinateSnake ? 'enabled' : 'disabled'}`);
+ Utils.showAlert(
+ `Snake pattern ${state.coordinateSnake ? 'enabled' : 'disabled'}`,
+ 'success'
+ );
+ });
+ }
+
+ if (blockWidthInput) {
+ blockWidthInput.value = state.blockWidth;
+ blockWidthInput.addEventListener('input', (e) => {
+ const width = parseInt(e.target.value);
+ if (width >= 1 && width <= 50) {
+ state.blockWidth = width;
+ saveBotSettings();
+ }
+ });
+ }
+
+ if (blockHeightInput) {
+ blockHeightInput.value = state.blockHeight;
+ blockHeightInput.addEventListener('change', (e) => {
+ const height = parseInt(e.target.value);
+ if (height >= 1 && height <= 50) {
+ state.blockHeight = height;
+ saveBotSettings();
+ }
+ });
+ }
+
+ if (compactBtn) {
+ compactBtn.addEventListener('click', () => {
+ container.classList.toggle('wplace-compact');
+ const isCompact = container.classList.contains('wplace-compact');
+
+ if (isCompact) {
+ compactBtn.innerHTML = '';
+ compactBtn.title = Utils.t('expandMode');
+ } else {
+ compactBtn.innerHTML = '';
+ compactBtn.title = Utils.t('compactMode');
+ }
+ });
+ }
+
+ if (minimizeBtn) {
+ minimizeBtn.addEventListener('click', () => {
+ state.minimized = !state.minimized;
+ if (state.minimized) {
+ container.classList.add('wplace-minimized');
+ content.classList.add('wplace-hidden');
+ minimizeBtn.innerHTML = '';
+ minimizeBtn.title = Utils.t('restore');
+ } else {
+ container.classList.remove('wplace-minimized');
+ content.classList.remove('wplace-hidden');
+ minimizeBtn.innerHTML = '';
+ minimizeBtn.title = Utils.t('minimize');
+ }
+ saveBotSettings();
+ });
+ }
+
+ if (toggleOverlayBtn) {
+ toggleOverlayBtn.addEventListener('click', () => {
+ const isEnabled = overlayManager.toggle();
+ toggleOverlayBtn.classList.toggle('active', isEnabled);
+ toggleOverlayBtn.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
+ Utils.showAlert(isEnabled ? Utils.t('overlayEnabled') : Utils.t('overlayDisabled'), 'info');
+ });
+ }
+
+ if (state.minimized) {
+ container.classList.add('wplace-minimized');
+ content.classList.add('wplace-hidden');
+ if (minimizeBtn) {
+ minimizeBtn.innerHTML = '';
+ minimizeBtn.title = Utils.t('restore');
+ }
+ } else {
+ container.classList.remove('wplace-minimized');
+ content.classList.remove('wplace-hidden');
+ if (minimizeBtn) {
+ minimizeBtn.innerHTML = '';
+ minimizeBtn.title = Utils.t('minimize');
+ }
+ }
+
+ if (saveBtn) {
+ saveBtn.addEventListener('click', () => {
+ if (!state.imageLoaded) {
+ Utils.showAlert(Utils.t('missingRequirements'), 'error');
+ return;
+ }
+
+ const success = Utils.saveProgress();
+ if (success) {
+ updateUI('autoSaved', 'success');
+ Utils.showAlert(Utils.t('autoSaved'), 'success');
+ } else {
+ Utils.showAlert(Utils.t('errorSavingProgress'), 'error');
+ }
+ });
+ }
+
+ if (loadBtn) {
+ loadBtn.addEventListener('click', async () => {
+ const savedData = Utils.loadProgress();
+ if (!savedData) {
+ updateUI('noSavedData', 'warning');
+ Utils.showAlert(Utils.t('noSavedData'), 'warning');
+ return;
+ }
+
+ // CRITICAL FIX: If save file contains complete data, bypass initial setup check
+ // This matches old version behavior where save files could be loaded immediately
+ const hasCompleteData = savedData.state && savedData.imageData &&
+ savedData.state.availableColors &&
+ savedData.state.availableColors.length > 0;
+
+ if (!state.initialSetupComplete && !hasCompleteData) {
+ Utils.showAlert(Utils.t('pleaseWaitInitialSetup'), 'warning');
+ return;
+ }
+
+ const confirmLoad = confirm(
+ `${Utils.t('savedDataFound')}\n\n` +
+ `Saved: ${new Date(savedData.timestamp).toLocaleString()}\n` +
+ `Progress: ${savedData.state.paintedPixels}/${savedData.state.totalPixels} pixels`
+ );
+
+ if (confirmLoad) {
+ const success = Utils.restoreProgress(savedData);
+ if (success) {
+ // CRITICAL FIX: Set initial setup complete if save had complete data
+ if (hasCompleteData) {
+ state.initialSetupComplete = true;
+ }
+
+ updateUI('dataLoaded', 'success');
+ Utils.showAlert(Utils.t('dataLoaded'), 'success');
+ updateDataButtons();
+
+ // CRITICAL FIX: Force immediate stats update after save loading
+ await updateStats();
+
+ // Force stats display refresh by updating the stats container
+ const statsContainer = document.getElementById('wplace-stats-container');
+ if (statsContainer && typeof updateStats === 'function') {
+ // Trigger a forced visual refresh of the stats
+ setTimeout(async () => {
+ await updateStats();
+ }, 100);
+ }
+
+ // Restore overlay if image data was loaded from localStorage
+ Utils.restoreOverlayFromData().catch((error) => {
+ console.error('Failed to restore overlay from localStorage:', error);
+ });
+
+ if (!state.colorsChecked) {
+ uploadBtn.disabled = false;
+ const loadExtractedBtn = document.getElementById('loadExtractedBtn');
+ if (loadExtractedBtn) loadExtractedBtn.disabled = false;
+ } else {
+ uploadBtn.disabled = false;
+ const loadExtractedBtn = document.getElementById('loadExtractedBtn');
+ if (loadExtractedBtn) loadExtractedBtn.disabled = false;
+ selectPosBtn.disabled = false;
+ }
+
+ if (state.imageLoaded && state.startPosition && state.region && state.colorsChecked) {
+ startBtn.disabled = false;
+ }
+ } else {
+ Utils.showAlert(Utils.t('errorLoadingProgress'), 'error');
+ }
+ }
+ });
+ }
+
+ if (saveToFileBtn) {
+ saveToFileBtn.addEventListener('click', () => {
+ const success = Utils.saveProgressToFile();
+ if (success) {
+ updateUI('fileSaved', 'success');
+ Utils.showAlert(Utils.t('fileSaved'), 'success');
+ } else {
+ Utils.showAlert(Utils.t('fileError'), 'error');
+ }
+ });
+ }
+
+ if (loadFromFileBtn) {
+ loadFromFileBtn.addEventListener('click', async () => {
+ console.log('š [DEBUG] Load from file button clicked');
+ console.log('š [DEBUG] Initial setup complete:', state.initialSetupComplete);
+
+ try {
+ // First check if we can load the file - this will tell us if it has complete data
+ console.log('š [DEBUG] Starting file upload...');
+ const fileData = await Utils.createFileUploader();
+
+ console.log('š [DEBUG] File data received:', !!fileData);
+ console.log('š [DEBUG] File data keys:', fileData ? Object.keys(fileData) : 'N/A');
+
+ if (!fileData || !fileData.state) {
+ console.error('ā [DEBUG] Invalid file data or missing state');
+ console.log('š [DEBUG] FileData:', fileData);
+ Utils.showAlert(Utils.t('invalidFileFormat'), 'error');
+ return;
+ }
+
+ // CRITICAL FIX: If file contains complete data, bypass initial setup check
+ const hasCompleteData = fileData.state && fileData.imageData &&
+ fileData.state.availableColors &&
+ fileData.state.availableColors.length > 0;
+
+ console.log('š [DEBUG] Has complete data check:');
+ console.log(' - fileData.state:', !!fileData.state);
+ console.log(' - fileData.imageData:', !!fileData.imageData);
+ console.log(' - fileData.state.availableColors:', !!fileData.state.availableColors);
+ console.log(' - availableColors length:', fileData.state.availableColors?.length);
+ console.log(' - hasCompleteData result:', hasCompleteData);
+
+ if (!state.initialSetupComplete && !hasCompleteData) {
+ console.log('ā ļø [DEBUG] Setup not complete and no complete data, showing warning');
+ Utils.showAlert(Utils.t('pleaseWaitFileSetup'), 'warning');
+ return;
+ }
+
+ console.log('š [DEBUG] Calling restoreProgress...');
+ const success = Utils.restoreProgress(fileData);
+ console.log('š [DEBUG] RestoreProgress result:', success);
+
+ if (success) {
+ console.log('ā
[DEBUG] File loaded successfully');
+
+ // CRITICAL FIX: Set initial setup complete if file had complete data
+ if (hasCompleteData) {
+ console.log('š [DEBUG] Setting initialSetupComplete to true due to complete data');
+ state.initialSetupComplete = true;
+ }
+
+ updateUI('fileLoaded', 'success');
+ Utils.showAlert(Utils.t('fileLoaded'), 'success');
+ updateDataButtons();
+
+ console.log('š [DEBUG] Updating stats...');
+ await updateStats();
+
+ // Restore overlay if image data was loaded from file
+ console.log('š [DEBUG] Attempting to restore overlay...');
+ await Utils.restoreOverlayFromData().catch((error) => {
+ console.error('ā [DEBUG] Failed to restore overlay from file:', error);
+ });
+
+ console.log('š [DEBUG] Updating button states after load...');
+ console.log(' - state.colorsChecked:', state.colorsChecked);
+ console.log(' - state.imageLoaded:', state.imageLoaded);
+ console.log(' - state.startPosition:', !!state.startPosition);
+ console.log(' - state.region:', !!state.region);
+
+ if (state.colorsChecked) {
+ console.log('š [DEBUG] Enabling buttons due to colorsChecked');
+ uploadBtn.disabled = false;
+ selectPosBtn.disabled = false;
+ resizeBtn.disabled = false;
+ } else {
+ console.log('š [DEBUG] Only enabling upload button');
+ uploadBtn.disabled = false;
+ }
+
+ if (state.imageLoaded && state.startPosition && state.region && state.colorsChecked) {
+ console.log('š [DEBUG] All conditions met, enabling start button');
+ startBtn.disabled = false;
+ } else {
+ console.log('ā ļø [DEBUG] Start button requirements not met');
+ }
+ } else {
+ console.error('ā [DEBUG] RestoreProgress failed');
+ Utils.showAlert('File loading failed - check console for details', 'error');
+ }
+ } catch (error) {
+ console.error('ā [DEBUG] Error in file load process:', error);
+ console.error('ā [DEBUG] Error stack:', error.stack);
+
+ if (error.message === 'Invalid JSON file') {
+ console.error('ā [DEBUG] Invalid JSON file detected');
+ Utils.showAlert(Utils.t('invalidFileFormat'), 'error');
+ } else {
+ console.error('ā [DEBUG] General file error:', error.message);
+ Utils.showAlert(`File error: ${error.message}`, 'error');
+ }
+ }
+ });
+ }
+
+ updateUI = (messageKey, type = 'default', params = {}, silent = false) => {
+ const message = Utils.t(messageKey, params);
+ statusText.textContent = message;
+ statusText.className = `wplace-status status-${type}`;
+
+ if (!silent) {
+ // Trigger animation only when silent = false
+ statusText.style.animation = 'none';
+ void statusText.offsetWidth; // trick to restart the animation
+ statusText.style.animation = 'slide-in 0.3s ease-out';
+ }
+ };
+
+ function updateChargeStatsDisplay(intervalMs) {
+ const currentChargesEl = document.getElementById('wplace-stat-charges-value');
+ const fullChargeEl = document.getElementById('wplace-stat-fullcharge-value');
+ if (!fullChargeEl && !currentChargesEl) return;
+ if (!state.fullChargeData) {
+ fullChargeEl.textContent = '--:--:--';
+ return;
+ }
+
+ const { current, max, cooldownMs, startTime, spentSinceShot } = state.fullChargeData;
+ const elapsed = Date.now() - startTime;
+
+ // total charges including elapsed time and spent during painting since snapshot
+ const chargesGained = elapsed / cooldownMs;
+ const rawCharges = current + chargesGained - spentSinceShot;
+ const cappedCharges = Math.min(rawCharges, max);
+
+ // rounding with 0.95 threshold
+ let displayCharges;
+ const fraction = cappedCharges - Math.floor(cappedCharges);
+ if (fraction >= 0.95) {
+ displayCharges = Math.ceil(cappedCharges);
+ } else {
+ displayCharges = Math.floor(cappedCharges);
+ }
+
+ state.displayCharges = Math.max(0, displayCharges);
+ state.preciseCurrentCharges = cappedCharges;
+
+ const remainingMs = getMsToTargetCharges(cappedCharges, max, state.cooldown, intervalMs);
+ const timeText = Utils.msToTimeText(remainingMs);
+
+ if (currentChargesEl) {
+ currentChargesEl.innerHTML = `${state.displayCharges} / ${state.maxCharges}`;
+ }
+
+ if (
+ state.displayCharges < state.cooldownChargeThreshold &&
+ !state.stopFlag &&
+ state.running
+ ) {
+ updateChargesThresholdUI(intervalMs);
+ }
+
+ if (fullChargeEl) {
+ if (state.displayCharges >= max) {
+ fullChargeEl.innerHTML = `FULL`;
+ } else {
+ fullChargeEl.innerHTML = `
+ ${timeText}
+ `;
+ }
+ }
+ }
+
+ updateStats = async (isManualRefresh = false) => {
+ const isFirstCheck = !state.fullChargeData?.startTime;
+
+ const minUpdateInterval = 60_000;
+ const maxUpdateInterval = 90_000;
+ const randomUpdateThreshold =
+ minUpdateInterval + Math.random() * (maxUpdateInterval - minUpdateInterval);
+ const timeSinceLastUpdate = Date.now() - (state.fullChargeData?.startTime || 0);
+ const isTimeToUpdate = timeSinceLastUpdate >= randomUpdateThreshold;
+
+ const shouldCallApi = isFirstCheck || isTimeToUpdate;
+
+ const { charges, max, cooldown } = await WPlaceService.getCharges();
+ state.displayCharges = Math.floor(charges);
+ state.preciseCurrentCharges = charges;
+ state.cooldown = cooldown;
+ state.maxCharges = Math.floor(max) > 1 ? Math.floor(max) : state.maxCharges;
+
+ state.fullChargeData = {
+ current: charges,
+ max: max,
+ cooldownMs: cooldown,
+ startTime: Date.now(),
+ spentSinceShot: 0,
+ };
+ // Evaluate notifications every time we refresh server-side charges
+ NotificationManager.maybeNotifyChargesReached();
+
+ if (state.fullChargeInterval) {
+ clearInterval(state.fullChargeInterval);
+ state.fullChargeInterval = null;
+ }
+ const intervalMs = 1000;
+ state.fullChargeInterval = setInterval(
+ () => updateChargeStatsDisplay(intervalMs),
+ intervalMs
+ );
+
+ if (cooldownSlider && cooldownSlider.max !== state.maxCharges) {
+ cooldownSlider.max = state.maxCharges;
+ }
+ if (cooldownInput && cooldownInput.max !== state.maxCharges) {
+ cooldownInput.max = state.maxCharges;
+ }
+
+ // // Update current account charges in the account list
+ // await updateCurrentAccountInList();
+
+ let imageStatsHTML = '';
+ if (state.imageLoaded) {
+ const progress =
+ state.totalPixels > 0 ? Math.round((state.paintedPixels / state.totalPixels) * 100) : 0;
+ const remainingPixels = state.totalPixels - state.paintedPixels;
+
+ // Updated estimation calculation for new cooldown logic
+ // Now we wait for cooldownChargeThreshold before processing pixels
+
+ // Calculate batch size (with fallback if function not yet defined)
+ let batchSize;
+ try {
+ batchSize = typeof calculateBatchSize === 'function' ? calculateBatchSize() : state.paintingSpeed || 5;
+ } catch (e) {
+ batchSize = state.paintingSpeed || 5;
+ }
+
+ // Ensure batchSize is valid and not zero
+ batchSize = Math.max(1, Math.min(batchSize, remainingPixels));
+ const batchesNeeded = Math.ceil(remainingPixels / batchSize);
+
+ // Calculate time accounting for cooldown threshold behavior with safety checks
+ let estimatedMs = 0;
+ if (remainingPixels > 0 && Number.isFinite(state.cooldown) && state.cooldown > 0) {
+ // Current charges available check
+ const currentCharges = Math.max(0, state.displayCharges);
+ const thresholdCharges = Math.max(1, state.cooldownChargeThreshold || 1);
+
+ if (currentCharges < thresholdCharges) {
+ // Need to wait for charges to reach threshold first
+ const chargesNeeded = thresholdCharges - currentCharges;
+ estimatedMs += chargesNeeded * state.cooldown;
+ }
+
+ // Calculate painting time more accurately
+ // Each batch uses thresholdCharges, so calculate how many cycles we need
+ const pixelsPerCycle = Math.min(batchSize, thresholdCharges);
+ const cyclesNeeded = Math.ceil(remainingPixels / pixelsPerCycle);
+
+ // Time between cycles (waiting for charges to regenerate)
+ if (cyclesNeeded > 1) {
+ const regenerationTime = (cyclesNeeded - 1) * thresholdCharges * state.cooldown;
+ estimatedMs += regenerationTime;
+ }
+
+ // Add painting speed delay if enabled
+ if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0) {
+ const paintingDelay = remainingPixels * (1000 / state.paintingSpeed);
+ estimatedMs += paintingDelay;
+ }
+ }
+
+ // Safety check to prevent infinity or invalid values
+ if (!Number.isFinite(estimatedMs) || estimatedMs < 0) {
+ estimatedMs = 0;
+ }
+
+ state.estimatedTime = estimatedMs;
+ progressBar.style.width = `${progress}%`;
+
+ imageStatsHTML = `
+
+
${Utils.t('progress')}
+
${progress}%
+
+
+
${Utils.t(
+ 'pixels'
+ )}
+
${state.paintedPixels}/${state.totalPixels}
+
+ `;
+ }
+
+ let colorSwatchesHTML = '';
+ state.availableColors = state.availableColors.filter(
+ (c) => c.name !== 'Unknown CoIor NaN' && c.id !== null
+ );
+
+ const availableColors = Utils.extractAvailableColors();
+ const newCount = Array.isArray(availableColors) ? availableColors.length : 0;
+
+ if (newCount === 0 && isManualRefresh) {
+ Utils.showAlert(Utils.t('noColorsFound'), 'warning');
+ } else if (newCount > 0 && state.availableColors.length < newCount) {
+ const oldCount = state.availableColors.length;
+
+ Utils.showAlert(
+ Utils.t('colorsUpdated', {
+ oldCount,
+ newCount: newCount,
+ diffCount: newCount - oldCount,
+ }),
+ 'success'
+ );
+ state.availableColors = availableColors;
+ }
+ if (state.colorsChecked) {
+ colorSwatchesHTML = state.availableColors
+ .map((color) => {
+ const rgbString = `rgb(${color.rgb.join(',')})`;
+ return ``;
+ })
+ .join('');
+ }
+
+ // Calculate multi-account statistics
+ let totalAllCharges = 0;
+ let totalMaxCharges = 0;
+ const accounts = accountManager.getAllAccounts();
+ if (accounts.length > 0) {
+ // Use real-time charges for current account, stored data for others
+ const currentAccount = accountManager.getCurrentAccount();
+ totalAllCharges = accounts.reduce((sum, acc) => {
+ if (currentAccount && acc.token === currentAccount.token) {
+ // Use real-time data for current account
+ return sum + Math.floor(state.displayCharges || state.preciseCurrentCharges || 0);
+ } else {
+ // Use stored data for other accounts
+ return sum + Math.floor(acc.Charges || 0);
+ }
+ }, 0);
+ totalMaxCharges = accounts.reduce((sum, acc) => {
+ if (currentAccount && acc.token === currentAccount.token) {
+ // Use real-time max charges for current account
+ return sum + Math.floor(state.maxCharges || acc.Max || 0);
+ } else {
+ // Use stored data for other accounts
+ return sum + Math.floor(acc.Max || 0);
+ }
+ }, 0);
+ }
+
+ statsArea.innerHTML = `
+ ${imageStatsHTML}
+ ${accounts.length > 0 ? `
+
+
+ Total All Accounts Charges
+
+
+ ${totalAllCharges}/${totalMaxCharges}
+
+
+ ` : ''}
+
+
+ ${Utils.t('charges')}
+
+
+ ${state.displayCharges} / ${state.maxCharges}
+
+
+
+
+ ${Utils.t('fullChargeIn')}
+
+
--:--:--
+
+ ${state.colorsChecked
+ ? `
+
+
${Utils.t(
+ 'availableColors',
+ { count: state.availableColors.length }
+ )}
+
+ ${colorSwatchesHTML}
+
+
+ `
+ : ''
+ }
+ `;
+
+ // should be after statsArea.innerHTML = '...'. todo make full stats ui update partial
+ updateChargeStatsDisplay(intervalMs);
+ };
+
+ updateDataButtons = () => {
+ const hasImageData = state.imageLoaded && state.imageData;
+ saveBtn.disabled = !hasImageData;
+ saveToFileBtn.disabled = !hasImageData;
+ };
+
+ updateDataButtons();
+
+ function showMoveArtworkPanel() {
+ // Create move artwork control panel like status panels
+ const movePanel = document.createElement('div');
+ movePanel.id = 'moveArtworkPanel';
+ movePanel.className = 'wplace-move-panel';
+
+ movePanel.innerHTML = `
+
+
+
+
+
+
+
1px
+
+
+
+
+
+ `;
+
+ // No overlay - append directly to body like other panels
+ document.body.appendChild(movePanel);
+
+ // Make the panel draggable
+ makeDraggable(movePanel);
+
+ // Add event listeners for movement
+ let currentMessageTimeout = null;
+
+ function moveArtwork(deltaX, deltaY) {
+ if (state.startPosition && state.region) {
+ const newX = state.startPosition.x + deltaX;
+ const newY = state.startPosition.y + deltaY;
+
+ console.log(`š Moving artwork from (${state.startPosition.x}, ${state.startPosition.y}) to (${newX}, ${newY})`);
+
+ state.startPosition.x = newX;
+ state.startPosition.y = newY;
+
+ // Clear any existing message timeout and alerts immediately
+ if (currentMessageTimeout) {
+ clearTimeout(currentMessageTimeout);
+ currentMessageTimeout = null;
+ }
+ Utils.hideAlert(); // Hide current alert immediately
+
+ // Update overlay position if available
+ if (overlayManager) {
+ overlayManager.setPosition(state.startPosition, state.region)
+ .then(() => {
+ console.log('ā
Overlay position updated');
+ Utils.showAlert(`Moved to position (${newX}, ${newY})`, 'success');
+ // Set new timeout for this message
+ currentMessageTimeout = setTimeout(() => {
+ Utils.hideAlert();
+ currentMessageTimeout = null;
+ }, 1500); // Message disappears after 1.5 seconds
+ })
+ .catch(err => {
+ console.error('ā Failed to update overlay:', err);
+ Utils.showAlert('Failed to update overlay position', 'error');
+ });
+ } else {
+ Utils.showAlert(`Position updated to (${newX}, ${newY})`, 'success');
+ // Set new timeout for this message
+ currentMessageTimeout = setTimeout(() => {
+ Utils.hideAlert();
+ currentMessageTimeout = null;
+ }, 1500);
+ }
+ } else {
+ Utils.showAlert('Please select a position first before moving artwork', 'warning');
+ }
+ }
+
+ document.getElementById('moveUp').addEventListener('click', () => moveArtwork(0, -1));
+ document.getElementById('moveDown').addEventListener('click', () => moveArtwork(0, 1));
+ document.getElementById('moveLeft').addEventListener('click', () => moveArtwork(-1, 0));
+ document.getElementById('moveRight').addEventListener('click', () => moveArtwork(1, 0));
+
+ // Close panel
+ function closeMovePanel() {
+ if (currentMessageTimeout) {
+ clearTimeout(currentMessageTimeout);
+ currentMessageTimeout = null;
+ }
+ Utils.hideAlert();
+ document.body.removeChild(movePanel);
+ }
+
+ document.getElementById('closeMovePanel').addEventListener('click', closeMovePanel);
+ }
+
+ function showResizeDialog(processor) {
+ let baseProcessor = processor;
+ let width, height;
+ if (state.originalImage?.dataUrl) {
+ baseProcessor = new ImageProcessor(state.originalImage.dataUrl);
+ width = state.originalImage.width;
+ height = state.originalImage.height;
+ } else {
+ const dims = processor.getDimensions();
+ width = dims.width;
+ height = dims.height;
+ }
+ const aspectRatio = width / height;
+
+ const rs = state.resizeSettings;
+ widthSlider.max = width * 2;
+ heightSlider.max = height * 2;
+ let initialW = width;
+ let initialH = height;
+ if (
+ rs &&
+ Number.isFinite(rs.width) &&
+ Number.isFinite(rs.height) &&
+ rs.width > 0 &&
+ rs.height > 0
+ ) {
+ initialW = rs.width;
+ initialH = rs.height;
+ }
+ // Clamp to slider ranges
+ initialW = Math.max(
+ parseInt(widthSlider.min, 10) || 10,
+ Math.min(initialW, parseInt(widthSlider.max, 10))
+ );
+ initialH = Math.max(
+ parseInt(heightSlider.min, 10) || 10,
+ Math.min(initialH, parseInt(heightSlider.max, 10))
+ );
+ widthSlider.value = initialW;
+ heightSlider.value = initialH;
+ widthValue.textContent = initialW;
+ heightValue.textContent = initialH;
+ zoomSlider.value = 1;
+ if (zoomValue) zoomValue.textContent = '100%';
+ paintWhiteToggle.checked = state.paintWhitePixels;
+ paintTransparentToggle.checked = state.paintTransparentPixels;
+
+ let _previewTimer = null;
+ let _previewJobId = 0;
+ let _isDraggingSize = false;
+ let _zoomLevel = 1;
+ let _ditherWorkBuf = null;
+ let _ditherEligibleBuf = null;
+ const ensureDitherBuffers = (n) => {
+ if (!_ditherWorkBuf || _ditherWorkBuf.length !== n * 3)
+ _ditherWorkBuf = new Float32Array(n * 3);
+ if (!_ditherEligibleBuf || _ditherEligibleBuf.length !== n)
+ _ditherEligibleBuf = new Uint8Array(n);
+ return { work: _ditherWorkBuf, eligible: _ditherEligibleBuf };
+ };
+ let _maskImageData = null;
+ let _maskData = null;
+ let _dirty = null;
+ const _resetDirty = () => {
+ _dirty = { minX: Infinity, minY: Infinity, maxX: -1, maxY: -1 };
+ };
+ const _markDirty = (x, y) => {
+ if (!_dirty) _resetDirty();
+ if (x < _dirty.minX) _dirty.minX = x;
+ if (y < _dirty.minY) _dirty.minY = y;
+ if (x > _dirty.maxX) _dirty.maxX = x;
+ if (y > _dirty.maxY) _dirty.maxY = y;
+ };
+ const _flushDirty = () => {
+ if (!_dirty || _dirty.maxX < _dirty.minX || _dirty.maxY < _dirty.minY) return;
+ const x = Math.max(0, _dirty.minX);
+ const y = Math.max(0, _dirty.minY);
+ const w = Math.min(maskCanvas.width - x, _dirty.maxX - x + 1);
+ const h = Math.min(maskCanvas.height - y, _dirty.maxY - y + 1);
+ if (w > 0 && h > 0) maskCtx.putImageData(_maskImageData, 0, 0, x, y, w, h);
+ _resetDirty();
+ };
+ const _ensureMaskOverlayBuffers = (w, h, rebuildFromMask = false) => {
+ if (!_maskImageData || _maskImageData.width !== w || _maskImageData.height !== h) {
+ _maskImageData = maskCtx.createImageData(w, h);
+ _maskData = _maskImageData.data;
+ rebuildFromMask = true;
+ }
+ if (rebuildFromMask) {
+ const m = state.resizeIgnoreMask;
+ const md = _maskData;
+ md.fill(0);
+ if (m) {
+ for (let i = 0; i < m.length; i++)
+ if (m[i]) {
+ const p = i * 4;
+ md[p] = 255;
+ md[p + 1] = 0;
+ md[p + 2] = 0;
+ md[p + 3] = 150;
+ }
+ }
+ maskCtx.putImageData(_maskImageData, 0, 0);
+ _resetDirty();
+ }
+ };
+ const ensureMaskSize = (w, h) => {
+ if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
+ state.resizeIgnoreMask = new Uint8Array(w * h);
+ }
+ baseCanvas.width = w;
+ baseCanvas.height = h;
+ maskCanvas.width = w;
+ maskCanvas.height = h;
+ maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
+ // Ensure overlay buffers exist and rebuild from mask when dimensions change
+ _ensureMaskOverlayBuffers(w, h, true);
+ };
+ _updateResizePreview = async () => {
+ const jobId = ++_previewJobId;
+ const newWidth = parseInt(widthSlider.value, 10);
+ const newHeight = parseInt(heightSlider.value, 10);
+ _zoomLevel = parseFloat(zoomSlider.value);
+
+ widthValue.textContent = newWidth;
+ heightValue.textContent = newHeight;
+
+ ensureMaskSize(newWidth, newHeight);
+ canvasStack.style.width = newWidth + 'px';
+ canvasStack.style.height = newHeight + 'px';
+ baseCtx.imageSmoothingEnabled = false;
+
+ if (!state.availableColors || state.availableColors.length === 0) {
+ if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
+ await baseProcessor.load();
+ }
+ baseCtx.clearRect(0, 0, newWidth, newHeight);
+ baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
+ // Draw existing mask overlay buffer
+ maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
+ if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
+ updateZoomLayout();
+ return;
+ }
+
+ if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
+ await baseProcessor.load();
+ }
+ baseCtx.clearRect(0, 0, newWidth, newHeight);
+ baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
+ const imgData = baseCtx.getImageData(0, 0, newWidth, newHeight);
+ const data = imgData.data;
+
+ const tThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
+
+ const applyFSDither = () => {
+ const w = newWidth,
+ h = newHeight;
+ const n = w * h;
+ const { work, eligible } = ensureDitherBuffers(n);
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const idx = y * w + x;
+ const i4 = idx * 4;
+ const r = data[i4],
+ g = data[i4 + 1],
+ b = data[i4 + 2],
+ a = data[i4 + 3];
+ const isEligible =
+ (state.paintTransparentPixels || a >= tThresh) &&
+ (state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
+ eligible[idx] = isEligible ? 1 : 0;
+ work[idx * 3] = r;
+ work[idx * 3 + 1] = g;
+ work[idx * 3 + 2] = b;
+ if (!isEligible) {
+ data[i4 + 3] = 0; // transparent in preview overlay
+ }
+ }
+ }
+
+ const diffuse = (nx, ny, er, eg, eb, factor) => {
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
+ const nidx = ny * w + nx;
+ if (!eligible[nidx]) return;
+ const base = nidx * 3;
+ work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
+ work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
+ work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
+ };
+
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const idx = y * w + x;
+ if (!eligible[idx]) continue;
+ const base = idx * 3;
+ const r0 = work[base],
+ g0 = work[base + 1],
+ b0 = work[base + 2];
+ const [nr, ng, nb] = Utils.findClosestPaletteColor(
+ r0,
+ g0,
+ b0,
+ state.activeColorPalette
+ );
+ const i4 = idx * 4;
+ data[i4] = nr;
+ data[i4 + 1] = ng;
+ data[i4 + 2] = nb;
+ data[i4 + 3] = 255;
+
+ const er = r0 - nr;
+ const eg = g0 - ng;
+ const eb = b0 - nb;
+
+ diffuse(x + 1, y, er, eg, eb, 7 / 16);
+ diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
+ diffuse(x, y + 1, er, eg, eb, 5 / 16);
+ diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
+ }
+ }
+ };
+
+ // Skip expensive dithering while user is dragging sliders
+ if (state.ditheringEnabled && !_isDraggingSize) {
+ applyFSDither();
+ } else {
+ 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 (
+ (!state.paintTransparentPixels && a < tThresh) ||
+ (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
+ ) {
+ data[i + 3] = 0;
+ continue;
+ }
+ const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
+ data[i] = nr;
+ data[i + 1] = ng;
+ data[i + 2] = nb;
+ data[i + 3] = 255;
+ }
+ }
+
+ if (jobId !== _previewJobId) return;
+ baseCtx.putImageData(imgData, 0, 0);
+ maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
+ if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
+ updateZoomLayout();
+ };
+
+ const onWidthInput = () => {
+ if (keepAspect.checked) {
+ heightSlider.value = Math.round(parseInt(widthSlider.value, 10) / aspectRatio);
+ }
+ _updateResizePreview();
+ const curW = parseInt(widthSlider.value, 10);
+ const curH = parseInt(heightSlider.value, 10);
+ state.resizeSettings = {
+ baseWidth: width,
+ baseHeight: height,
+ width: curW,
+ height: curH,
+ };
+ saveBotSettings();
+ // Auto-fit after size changes
+ const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
+ if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
+ };
+
+ const onHeightInput = () => {
+ if (keepAspect.checked) {
+ widthSlider.value = Math.round(parseInt(heightSlider.value, 10) * aspectRatio);
+ }
+ _updateResizePreview();
+ const curW = parseInt(widthSlider.value, 10);
+ const curH = parseInt(heightSlider.value, 10);
+ state.resizeSettings = {
+ baseWidth: width,
+ baseHeight: height,
+ width: curW,
+ height: curH,
+ };
+ saveBotSettings();
+ // Auto-fit after size changes
+ const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
+ if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
+ };
+
+ paintWhiteToggle.onchange = (e) => {
+ state.paintWhitePixels = e.target.checked;
+ _updateResizePreview();
+ saveBotSettings();
+ };
+
+ paintTransparentToggle.onchange = (e) => {
+ state.paintTransparentPixels = e.target.checked;
+ _updateResizePreview();
+ saveBotSettings();
+ };
+
+ let panX = 0,
+ panY = 0;
+ const clampPan = () => {
+ const wrapRect = panStage?.getBoundingClientRect() || {
+ width: 0,
+ height: 0,
+ };
+ const w = (baseCanvas.width || 1) * _zoomLevel;
+ const h = (baseCanvas.height || 1) * _zoomLevel;
+ if (w <= wrapRect.width) {
+ panX = Math.floor((wrapRect.width - w) / 2);
+ } else {
+ const minX = wrapRect.width - w;
+ panX = Math.min(0, Math.max(minX, panX));
+ }
+ if (h <= wrapRect.height) {
+ panY = Math.floor((wrapRect.height - h) / 2);
+ } else {
+ const minY = wrapRect.height - h;
+ panY = Math.min(0, Math.max(minY, panY));
+ }
+ };
+ let _panRaf = 0;
+ const applyPan = () => {
+ if (_panRaf) return;
+ _panRaf = requestAnimationFrame(() => {
+ clampPan();
+ canvasStack.style.transform = `translate3d(${Math.round(
+ panX
+ )}px, ${Math.round(panY)}px, 0) scale(${_zoomLevel})`;
+ _panRaf = 0;
+ });
+ };
+
+ const updateZoomLayout = () => {
+ const w = baseCanvas.width || 1,
+ h = baseCanvas.height || 1;
+ baseCanvas.style.width = w + 'px';
+ baseCanvas.style.height = h + 'px';
+ maskCanvas.style.width = w + 'px';
+ maskCanvas.style.height = h + 'px';
+ canvasStack.style.width = w + 'px';
+ canvasStack.style.height = h + 'px';
+ applyPan();
+ };
+ const applyZoom = (z) => {
+ _zoomLevel = Math.max(0.05, Math.min(20, z || 1));
+ zoomSlider.value = _zoomLevel;
+ updateZoomLayout();
+ if (zoomValue) zoomValue.textContent = `${Math.round(_zoomLevel * 100)}%`;
+ };
+ zoomSlider.addEventListener('input', () => {
+ applyZoom(parseFloat(zoomSlider.value));
+ });
+ if (zoomInBtn)
+ zoomInBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) + 0.1));
+ if (zoomOutBtn)
+ zoomOutBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) - 0.1));
+ const computeFitZoom = () => {
+ const wrapRect = panStage?.getBoundingClientRect();
+ if (!wrapRect) return 1;
+ const w = baseCanvas.width || 1;
+ const h = baseCanvas.height || 1;
+ const margin = 10;
+ const scaleX = (wrapRect.width - margin) / w;
+ const scaleY = (wrapRect.height - margin) / h;
+ return Math.max(0.05, Math.min(20, Math.min(scaleX, scaleY)));
+ };
+ if (zoomFitBtn)
+ zoomFitBtn.addEventListener('click', () => {
+ applyZoom(computeFitZoom());
+ centerInView();
+ });
+ if (zoomActualBtn)
+ zoomActualBtn.addEventListener('click', () => {
+ applyZoom(1);
+ centerInView();
+ });
+
+ const centerInView = () => {
+ if (!panStage) return;
+ const rect = panStage.getBoundingClientRect();
+ const w = (baseCanvas.width || 1) * _zoomLevel;
+ const h = (baseCanvas.height || 1) * _zoomLevel;
+ panX = Math.floor((rect.width - w) / 2);
+ panY = Math.floor((rect.height - h) / 2);
+ applyPan();
+ };
+
+ let isPanning = false;
+ let startX = 0,
+ startY = 0,
+ startPanX = 0,
+ startPanY = 0;
+ let allowPan = false; // Space key
+ let panMode = false; // Explicit pan mode toggle for touch/one-button mice
+ const isPanMouseButton = (e) => e.button === 1 || e.button === 2;
+ const setCursor = (val) => {
+ if (panStage) panStage.style.cursor = val;
+ };
+ const isPanActive = (e) => panMode || allowPan || isPanMouseButton(e);
+ const updatePanModeBtn = () => {
+ if (!panModeBtn) return;
+ panModeBtn.classList.toggle('active', panMode);
+ panModeBtn.setAttribute('aria-pressed', panMode ? 'true' : 'false');
+ };
+ if (panModeBtn) {
+ updatePanModeBtn();
+ panModeBtn.addEventListener('click', () => {
+ panMode = !panMode;
+ updatePanModeBtn();
+ setCursor(panMode ? 'grab' : '');
+ });
+ }
+ if (panStage) {
+ panStage.addEventListener('contextmenu', (e) => {
+ if (allowPan) e.preventDefault();
+ });
+ window.addEventListener('keydown', (e) => {
+ if (e.code === 'Space') {
+ allowPan = true;
+ setCursor('grab');
+ }
+ });
+ window.addEventListener('keyup', (e) => {
+ if (e.code === 'Space') {
+ allowPan = false;
+ if (!isPanning) setCursor('');
+ }
+ });
+ panStage.addEventListener('mousedown', (e) => {
+ if (!isPanActive(e)) return;
+ e.preventDefault();
+ isPanning = true;
+ startX = e.clientX;
+ startY = e.clientY;
+ startPanX = panX;
+ startPanY = panY;
+ setCursor('grabbing');
+ });
+ window.addEventListener('mousemove', (e) => {
+ if (!isPanning) return;
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
+ panX = startPanX + dx;
+ panY = startPanY + dy;
+ applyPan();
+ });
+ window.addEventListener('mouseup', () => {
+ if (isPanning) {
+ isPanning = false;
+ setCursor(allowPan ? 'grab' : '');
+ }
+ });
+ panStage.addEventListener(
+ 'wheel',
+ (e) => {
+ if (!e.ctrlKey && !e.metaKey) return;
+ e.preventDefault();
+ const rect = panStage.getBoundingClientRect();
+ const cx = e.clientX - rect.left - panX;
+ const cy = e.clientY - rect.top - panY;
+ const before = _zoomLevel;
+ const step = Math.max(0.05, Math.min(0.5, Math.abs(e.deltaY) > 20 ? 0.2 : 0.1));
+ const next = Math.max(0.05, Math.min(20, before + (e.deltaY > 0 ? -step : step)));
+ if (next === before) return;
+ const scale = next / before;
+ panX = panX - cx * (scale - 1);
+ panY = panY - cy * (scale - 1);
+ applyZoom(next);
+ },
+ { passive: false }
+ );
+ let lastTouchDist = null;
+ let touchStartTime = 0;
+ let doubleTapTimer = null;
+ panStage.addEventListener(
+ 'touchstart',
+ (e) => {
+ if (e.touches.length === 1) {
+ const t = e.touches[0];
+ isPanning = true;
+ startX = t.clientX;
+ startY = t.clientY;
+ startPanX = panX;
+ startPanY = panY;
+ setCursor('grabbing');
+ const now = Date.now();
+ if (now - touchStartTime < 300) {
+ // double tap -> toggle 100%/fit
+ const z = Math.abs(_zoomLevel - 1) < 0.01 ? computeFitZoom() : 1;
+ applyZoom(z);
+ centerInView();
+ if (doubleTapTimer) clearTimeout(doubleTapTimer);
+ } else {
+ touchStartTime = now;
+ doubleTapTimer = setTimeout(() => {
+ doubleTapTimer = null;
+ }, 320);
+ }
+ } else if (e.touches.length === 2) {
+ // Pinch start
+ const [a, b] = e.touches;
+ lastTouchDist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
+ }
+ },
+ { passive: true }
+ );
+ panStage.addEventListener(
+ 'touchmove',
+ (e) => {
+ if (e.touches.length === 1 && isPanning) {
+ const t = e.touches[0];
+ const dx = t.clientX - startX;
+ const dy = t.clientY - startY;
+ panX = startPanX + dx;
+ panY = startPanY + dy;
+ applyPan();
+ } else if (e.touches.length === 2 && lastTouchDist != null) {
+ e.preventDefault();
+ const [a, b] = e.touches;
+ const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
+ const rect = panStage.getBoundingClientRect();
+ const centerX = (a.clientX + b.clientX) / 2 - rect.left - panX;
+ const centerY = (a.clientY + b.clientY) / 2 - rect.top - panY;
+ const before = _zoomLevel;
+ const scale = dist / (lastTouchDist || dist);
+ const next = Math.max(0.05, Math.min(20, before * scale));
+ if (next !== before) {
+ panX = panX - centerX * (next / before - 1);
+ panY = panY - centerY * (next / before - 1);
+ applyZoom(next);
+ }
+ lastTouchDist = dist;
+ }
+ },
+ { passive: false }
+ );
+ panStage.addEventListener('touchend', () => {
+ isPanning = false;
+ lastTouchDist = null;
+ setCursor(panMode || allowPan ? 'grab' : '');
+ });
+ }
+ const schedulePreview = () => {
+ if (_previewTimer) clearTimeout(_previewTimer);
+ const run = () => {
+ _previewTimer = null;
+ _updateResizePreview();
+ };
+ if (window.requestIdleCallback) {
+ _previewTimer = setTimeout(() => requestIdleCallback(run, { timeout: 150 }), 50);
+ } else {
+ _previewTimer = setTimeout(() => requestAnimationFrame(run), 50);
+ }
+ };
+ // Track dragging to reduce work and skip dithering during drag
+ const markDragStart = () => {
+ _isDraggingSize = true;
+ };
+ const markDragEnd = () => {
+ _isDraggingSize = false;
+ schedulePreview();
+ };
+ widthSlider.addEventListener('pointerdown', markDragStart);
+ heightSlider.addEventListener('pointerdown', markDragStart);
+ widthSlider.addEventListener('pointerup', markDragEnd);
+ heightSlider.addEventListener('pointerup', markDragEnd);
+ widthSlider.addEventListener('input', () => {
+ onWidthInput();
+ schedulePreview();
+ });
+ heightSlider.addEventListener('input', () => {
+ onHeightInput();
+ schedulePreview();
+ });
+
+ // Mask painting UX: brush size, modes, row/column fills, and precise coords
+ let draggingMask = false;
+ let lastPaintX = -1,
+ lastPaintY = -1;
+ let brushSize = 1;
+ let rowColSize = 1;
+ let maskMode = 'ignore'; // 'ignore' | 'unignore' | 'toggle'
+ const brushEl = resizeContainer.querySelector('#maskBrushSize');
+ const brushValEl = resizeContainer.querySelector('#maskBrushSizeValue');
+ const btnIgnore = resizeContainer.querySelector('#maskModeIgnore');
+ const btnUnignore = resizeContainer.querySelector('#maskModeUnignore');
+ const btnToggle = resizeContainer.querySelector('#maskModeToggle');
+ const clearIgnoredBtnEl = resizeContainer.querySelector('#clearIgnoredBtn');
+ const invertMaskBtn = resizeContainer.querySelector('#invertMaskBtn');
+ const rowColSizeEl = resizeContainer.querySelector('#rowColSize');
+ const rowColSizeValEl = resizeContainer.querySelector('#rowColSizeValue');
+
+ const updateModeButtons = () => {
+ const map = [
+ [btnIgnore, 'ignore'],
+ [btnUnignore, 'unignore'],
+ [btnToggle, 'toggle'],
+ ];
+ for (const [el, m] of map) {
+ if (!el) continue;
+ const active = maskMode === m;
+ el.classList.toggle('active', active);
+ el.setAttribute('aria-pressed', active ? 'true' : 'false');
+ }
+ };
+ const setMode = (mode) => {
+ maskMode = mode;
+ updateModeButtons();
+ };
+ if (brushEl && brushValEl) {
+ brushEl.addEventListener('input', () => {
+ brushSize = parseInt(brushEl.value, 10) || 1;
+ brushValEl.textContent = brushSize;
+ });
+ brushValEl.textContent = brushEl.value;
+ brushSize = parseInt(brushEl.value, 10) || 1;
+ }
+ if (rowColSizeEl && rowColSizeValEl) {
+ rowColSizeEl.addEventListener('input', () => {
+ rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
+ rowColSizeValEl.textContent = rowColSize;
+ });
+ rowColSizeValEl.textContent = rowColSizeEl.value;
+ rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
+ }
+ if (btnIgnore) btnIgnore.addEventListener('click', () => setMode('ignore'));
+ if (btnUnignore) btnUnignore.addEventListener('click', () => setMode('unignore'));
+ if (btnToggle) btnToggle.addEventListener('click', () => setMode('toggle'));
+ // Initialize button state (default to toggle mode)
+ updateModeButtons();
+
+ const mapClientToPixel = (clientX, clientY) => {
+ // Compute without rounding until final step to avoid drift at higher zoom
+ const rect = baseCanvas.getBoundingClientRect();
+ const scaleX = rect.width / baseCanvas.width;
+ const scaleY = rect.height / baseCanvas.height;
+ const dx = (clientX - rect.left) / scaleX;
+ const dy = (clientY - rect.top) / scaleY;
+ const x = Math.floor(dx);
+ const y = Math.floor(dy);
+ return { x, y };
+ };
+
+ const ensureMask = (w, h) => {
+ if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
+ state.resizeIgnoreMask = new Uint8Array(w * h);
+ }
+ };
+
+ const paintCircle = (cx, cy, radius, value) => {
+ const w = baseCanvas.width,
+ h = baseCanvas.height;
+ ensureMask(w, h);
+ const r2 = radius * radius;
+ for (let yy = cy - radius; yy <= cy + radius; yy++) {
+ if (yy < 0 || yy >= h) continue;
+ for (let xx = cx - radius; xx <= cx + radius; xx++) {
+ if (xx < 0 || xx >= w) continue;
+ const dx = xx - cx,
+ dy = yy - cy;
+ if (dx * dx + dy * dy <= r2) {
+ const idx = yy * w + xx;
+ let val = state.resizeIgnoreMask[idx];
+ if (maskMode === 'toggle') {
+ val = val ? 0 : 1;
+ } else if (maskMode === 'ignore') {
+ val = 1;
+ } else {
+ val = 0;
+ }
+ state.resizeIgnoreMask[idx] = val;
+ if (_maskData) {
+ const p = idx * 4;
+ if (val) {
+ _maskData[p] = 255;
+ _maskData[p + 1] = 0;
+ _maskData[p + 2] = 0;
+ _maskData[p + 3] = 150;
+ } else {
+ _maskData[p] = 0;
+ _maskData[p + 1] = 0;
+ _maskData[p + 2] = 0;
+ _maskData[p + 3] = 0;
+ }
+ _markDirty(xx, yy);
+ }
+ }
+ }
+ }
+ };
+
+ const paintRow = (y, value) => {
+ const w = baseCanvas.width,
+ h = baseCanvas.height;
+ ensureMask(w, h);
+ if (y < 0 || y >= h) return;
+
+ // Paint multiple rows based on rowColSize
+ const halfSize = Math.floor(rowColSize / 2);
+ const startY = Math.max(0, y - halfSize);
+ const endY = Math.min(h - 1, y + halfSize);
+
+ for (let rowY = startY; rowY <= endY; rowY++) {
+ for (let x = 0; x < w; x++) {
+ const idx = rowY * w + x;
+ let val = state.resizeIgnoreMask[idx];
+ if (maskMode === 'toggle') {
+ val = val ? 0 : 1;
+ } else if (maskMode === 'ignore') {
+ val = 1;
+ } else {
+ val = 0;
+ }
+ state.resizeIgnoreMask[idx] = val;
+ if (_maskData) {
+ const p = idx * 4;
+ if (val) {
+ _maskData[p] = 255;
+ _maskData[p + 1] = 0;
+ _maskData[p + 2] = 0;
+ _maskData[p + 3] = 150;
+ } else {
+ _maskData[p] = 0;
+ _maskData[p + 1] = 0;
+ _maskData[p + 2] = 0;
+ _maskData[p + 3] = 0;
+ }
+ }
+ }
+ if (_maskData) {
+ _markDirty(0, rowY);
+ _markDirty(w - 1, rowY);
+ }
+ }
+ };
+
+ const paintColumn = (x, value) => {
+ const w = baseCanvas.width,
+ h = baseCanvas.height;
+ ensureMask(w, h);
+ if (x < 0 || x >= w) return;
+
+ // Paint multiple columns based on rowColSize
+ const halfSize = Math.floor(rowColSize / 2);
+ const startX = Math.max(0, x - halfSize);
+ const endX = Math.min(w - 1, x + halfSize);
+
+ for (let colX = startX; colX <= endX; colX++) {
+ for (let y = 0; y < h; y++) {
+ const idx = y * w + colX;
+ let val = state.resizeIgnoreMask[idx];
+ if (maskMode === 'toggle') {
+ val = val ? 0 : 1;
+ } else if (maskMode === 'ignore') {
+ val = 1;
+ } else {
+ val = 0;
+ }
+ state.resizeIgnoreMask[idx] = val;
+ if (_maskData) {
+ const p = idx * 4;
+ if (val) {
+ _maskData[p] = 255;
+ _maskData[p + 1] = 0;
+ _maskData[p + 2] = 0;
+ _maskData[p + 3] = 150;
+ } else {
+ _maskData[p] = 0;
+ _maskData[p + 1] = 0;
+ _maskData[p + 2] = 0;
+ _maskData[p + 3] = 0;
+ }
+ }
+ }
+ if (_maskData) {
+ _markDirty(colX, 0);
+ _markDirty(colX, h - 1);
+ }
+ }
+ };
+
+ const redrawMaskOverlay = () => {
+ // Only flush the dirty region; full rebuild happens on size change
+ _flushDirty();
+ };
+
+ const handlePaint = (e) => {
+ // Suppress painting while panning
+ if ((e.buttons & 4) === 4 || (e.buttons & 2) === 2 || allowPan) return;
+ const { x, y } = mapClientToPixel(e.clientX, e.clientY);
+ const w = baseCanvas.width,
+ h = baseCanvas.height;
+ if (x < 0 || y < 0 || x >= w || y >= h) return;
+ const radius = Math.max(1, Math.floor(brushSize / 2));
+ if (e.shiftKey) {
+ paintRow(y);
+ } else if (e.altKey) {
+ paintColumn(x);
+ } else {
+ paintCircle(x, y, radius);
+ }
+ lastPaintX = x;
+ lastPaintY = y;
+ redrawMaskOverlay();
+ };
+
+ maskCanvas.addEventListener('mousedown', (e) => {
+ if (e.button === 1 || e.button === 2 || allowPan) return; // let pan handler manage
+ draggingMask = true;
+ handlePaint(e);
+ });
+ // Avoid hijacking touch gestures for panning/zooming
+ maskCanvas.addEventListener(
+ 'touchstart',
+ (e) => {
+ /* let panStage handle */
+ },
+ { passive: true }
+ );
+ maskCanvas.addEventListener(
+ 'touchmove',
+ (e) => {
+ /* let panStage handle */
+ },
+ { passive: true }
+ );
+ maskCanvas.addEventListener(
+ 'touchend',
+ (e) => {
+ /* let panStage handle */
+ },
+ { passive: true }
+ );
+ window.addEventListener('mousemove', (e) => {
+ if (draggingMask) handlePaint(e);
+ });
+ window.addEventListener('mouseup', () => {
+ if (draggingMask) {
+ draggingMask = false;
+ saveBotSettings();
+ }
+ });
+
+ if (clearIgnoredBtnEl)
+ clearIgnoredBtnEl.addEventListener('click', () => {
+ const w = baseCanvas.width,
+ h = baseCanvas.height;
+ if (state.resizeIgnoreMask) state.resizeIgnoreMask.fill(0);
+ _ensureMaskOverlayBuffers(w, h, true);
+ _updateResizePreview();
+ saveBotSettings();
+ });
+
+ if (invertMaskBtn)
+ invertMaskBtn.addEventListener('click', () => {
+ if (!state.resizeIgnoreMask) return;
+ for (let i = 0; i < state.resizeIgnoreMask.length; i++)
+ state.resizeIgnoreMask[i] = state.resizeIgnoreMask[i] ? 0 : 1;
+ const w = baseCanvas.width,
+ h = baseCanvas.height;
+ _ensureMaskOverlayBuffers(w, h, true);
+ _updateResizePreview();
+ saveBotSettings();
+ });
+
+ confirmResize.onclick = async () => {
+ const newWidth = parseInt(widthSlider.value, 10);
+ const newHeight = parseInt(heightSlider.value, 10);
+
+ // Generate the final paletted image data
+ const tempCanvas = document.createElement('canvas');
+ const tempCtx = tempCanvas.getContext('2d');
+ tempCanvas.width = newWidth;
+ tempCanvas.height = newHeight;
+ tempCtx.imageSmoothingEnabled = false;
+ if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
+ await baseProcessor.load();
+ }
+ tempCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
+ const imgData = tempCtx.getImageData(0, 0, newWidth, newHeight);
+ const data = imgData.data;
+ const tThresh2 = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
+ let totalValidPixels = 0;
+ const mask =
+ state.resizeIgnoreMask && state.resizeIgnoreMask.length === newWidth * newHeight
+ ? state.resizeIgnoreMask
+ : null;
+
+ const applyFSDitherFinal = async () => {
+ const w = newWidth,
+ h = newHeight;
+ const n = w * h;
+ const { work, eligible } = ensureDitherBuffers(n);
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const idx = y * w + x;
+ const i4 = idx * 4;
+ const r = data[i4],
+ g = data[i4 + 1],
+ b = data[i4 + 2],
+ a = data[i4 + 3];
+ const masked = mask && mask[idx];
+ const isEligible =
+ !masked &&
+ (state.paintTransparentPixels || a >= tThresh2) &&
+ (state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
+ eligible[idx] = isEligible ? 1 : 0;
+ work[idx * 3] = r;
+ work[idx * 3 + 1] = g;
+ work[idx * 3 + 2] = b;
+ if (!isEligible) {
+ data[i4 + 3] = 0;
+ }
+ }
+ // Yield to keep UI responsive
+ if ((y & 15) === 0) await Promise.resolve();
+ }
+
+ const diffuse = (nx, ny, er, eg, eb, factor) => {
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
+ const nidx = ny * w + nx;
+ if (!eligible[nidx]) return;
+ const base = nidx * 3;
+ work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
+ work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
+ work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
+ };
+
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const idx = y * w + x;
+ if (!eligible[idx]) continue;
+ const base = idx * 3;
+ const r0 = work[base],
+ g0 = work[base + 1],
+ b0 = work[base + 2];
+ const [nr, ng, nb] = Utils.findClosestPaletteColor(
+ r0,
+ g0,
+ b0,
+ state.activeColorPalette
+ );
+ const i4 = idx * 4;
+ data[i4] = nr;
+ data[i4 + 1] = ng;
+ data[i4 + 2] = nb;
+ data[i4 + 3] = 255;
+ totalValidPixels++;
+
+ const er = r0 - nr;
+ const eg = g0 - ng;
+ const eb = b0 - nb;
+
+ diffuse(x + 1, y, er, eg, eb, 7 / 16);
+ diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
+ diffuse(x, y + 1, er, eg, eb, 5 / 16);
+ diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
+ }
+ // Yield every row to reduce jank
+ await Promise.resolve();
+ }
+ };
+
+ if (state.ditheringEnabled) {
+ await applyFSDitherFinal();
+ } else {
+ 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];
+ const masked = mask && mask[i >> 2];
+ const isTransparent = (!state.paintTransparentPixels && a < tThresh2) || masked;
+ const isWhiteAndSkipped = !state.paintWhitePixels && Utils.isWhitePixel(r, g, b);
+ if (isTransparent || isWhiteAndSkipped) {
+ data[i + 3] = 0; // overlay transparency
+ continue;
+ }
+ totalValidPixels++;
+ const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
+ data[i] = nr;
+ data[i + 1] = ng;
+ data[i + 2] = nb;
+ data[i + 3] = 255;
+ }
+ }
+ tempCtx.putImageData(imgData, 0, 0);
+
+ // Save the final pixel data for painting
+ // Persist the paletted (and possibly dithered) pixels so painting uses the same output seen in overlay
+ const palettedPixels = new Uint8ClampedArray(imgData.data);
+ state.imageData.pixels = palettedPixels;
+ state.imageData.width = newWidth;
+ state.imageData.height = newHeight;
+ state.imageData.totalPixels = totalValidPixels;
+ state.totalPixels = totalValidPixels;
+ state.paintedPixels = 0;
+
+ state.resizeSettings = {
+ baseWidth: width,
+ baseHeight: height,
+ width: newWidth,
+ height: newHeight,
+ };
+ saveBotSettings();
+
+ const finalImageBitmap = await createImageBitmap(tempCanvas);
+ await overlayManager.setImage(finalImageBitmap);
+ overlayManager.enable();
+ toggleOverlayBtn.classList.add('active');
+ toggleOverlayBtn.setAttribute('aria-pressed', 'true');
+
+ // Keep state.imageData.processor as the original-based source; painting uses paletted pixels already stored
+
+ await updateStats();
+ updateUI('resizeSuccess', 'success', {
+ width: newWidth,
+ height: newHeight,
+ });
+ closeResizeDialog();
+ };
+
+ downloadPreviewBtn.onclick = () => {
+ try {
+ const w = baseCanvas.width,
+ h = baseCanvas.height;
+ const out = document.createElement('canvas');
+ out.width = w;
+ out.height = h;
+ const octx = out.getContext('2d');
+ octx.imageSmoothingEnabled = false;
+ octx.drawImage(baseCanvas, 0, 0);
+ octx.drawImage(maskCanvas, 0, 0);
+ const link = document.createElement('a');
+ link.download = 'wplace-preview.png';
+ link.href = out.toDataURL();
+ link.click();
+ } catch (e) {
+ console.warn('Failed to download preview:', e);
+ }
+ };
+
+ cancelResize.onclick = closeResizeDialog;
+
+ editImageBtn.onclick = () => {
+ showEditPanel();
+ };
+
+ resizeOverlay.style.display = 'block';
+ resizeContainer.style.display = 'block';
+
+ // Reinitialize color palette with current available colors
+ initializeColorPalette(resizeContainer);
+
+ _updateResizePreview();
+ _resizeDialogCleanup = () => {
+ try {
+ zoomSlider.replaceWith(zoomSlider.cloneNode(true));
+ } catch { }
+ try {
+ if (zoomInBtn) zoomInBtn.replaceWith(zoomInBtn.cloneNode(true));
+ } catch { }
+ try {
+ if (zoomOutBtn) zoomOutBtn.replaceWith(zoomOutBtn.cloneNode(true));
+ } catch { }
+ };
+ setTimeout(() => {
+ if (typeof computeFitZoom === 'function') {
+ const z = computeFitZoom();
+ if (!isNaN(z) && isFinite(z)) {
+ applyZoom(z);
+ centerInView();
+ }
+ } else {
+ centerInView();
+ }
+ }, 0);
+ }
+
+ function closeResizeDialog() {
+ try {
+ if (typeof _resizeDialogCleanup === 'function') {
+ _resizeDialogCleanup();
+ }
+ } catch { }
+ resizeOverlay.style.display = 'none';
+ resizeContainer.style.display = 'none';
+ _updateResizePreview = () => { };
+ try {
+ if (typeof cancelAnimationFrame === 'function' && _panRaf) {
+ cancelAnimationFrame(_panRaf);
+ }
+ } catch { }
+ try {
+ if (_previewTimer) {
+ clearTimeout(_previewTimer);
+ _previewTimer = null;
+ }
+ } catch { }
+ _maskImageData = null;
+ _maskData = null;
+ _dirty = null;
+ _ditherWorkBuf = null;
+ _ditherEligibleBuf = null;
+ _resizeDialogCleanup = null;
+ }
+
+ function showEditPanel() {
+ try {
+ // Validate that we have a valid base canvas
+ if (!baseCanvas || !baseCanvas.width || !baseCanvas.height) {
+ Utils.showAlert('No image available for editing. Please upload an image first.', 'error');
+ return;
+ }
+
+ // Hide resize panel
+ resizeContainer.style.display = 'none';
+
+ // Create edit panel if it doesn't exist
+ let editOverlay = document.getElementById('editOverlay');
+ if (!editOverlay) {
+ createEditPanel();
+ editOverlay = document.getElementById('editOverlay');
+ }
+
+ // Get current image data from baseCanvas
+ const imageData = baseCanvas.toDataURL();
+
+ // Initialize edit panel with current image
+ initializeEditPanel(imageData);
+
+ // Show edit panel
+ editOverlay.style.display = 'block';
+
+ console.log('⨠Pixel Art Editor opened successfully');
+ } catch (error) {
+ console.error('Error opening pixel art editor:', error);
+ Utils.showAlert('Failed to open pixel art editor. Please try again.', 'error');
+ }
+ }
+
+ function createEditPanel() {
+ const editOverlay = document.createElement('div');
+ editOverlay.id = 'editOverlay';
+ editOverlay.className = 'edit-overlay';
+
+ editOverlay.innerHTML = `
+
+
+
+
+
+
+
+
+ Position: (0, 0) | Color: #000000 | Zoom: 100%
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(editOverlay);
+
+ // Set up event handlers
+ setupEditPanelEvents();
+ }
+
+ function setupEditPanelEvents() {
+ const editBackBtn = document.getElementById('editBackBtn');
+ const editApplyBtn = document.getElementById('editApplyBtn');
+ const paintBrush = document.getElementById('paintBrush');
+ const eraseTool = document.getElementById('eraseTool');
+ const eyedropperTool = document.getElementById('eyedropperTool');
+ const fillTool = document.getElementById('fillTool');
+ const brushSize = document.getElementById('brushSize');
+ const brushSizeValue = document.getElementById('brushSizeValue');
+ const showGrid = document.getElementById('showGrid');
+ const undoBtn = document.getElementById('undoBtn');
+ const redoBtn = document.getElementById('redoBtn');
+ const resetViewBtn = document.getElementById('resetViewBtn');
+ const editZoomIn = document.getElementById('editZoomIn');
+ const editZoomOut = document.getElementById('editZoomOut');
+ const zoomSelect = document.getElementById('zoomSelect');
+ const zoomFit = document.getElementById('zoomFit');
+ const zoom100 = document.getElementById('zoom100');
+ const minimapCanvas = document.getElementById('minimapCanvas');
+
+ // Back to resize panel
+ editBackBtn.onclick = () => {
+ document.getElementById('editOverlay').style.display = 'none';
+ resizeContainer.style.display = 'block';
+ };
+
+ // Apply changes
+ editApplyBtn.onclick = () => {
+ applyEditChanges();
+ document.getElementById('editOverlay').style.display = 'none';
+ resizeContainer.style.display = 'block';
+ };
+
+ // Tool selection
+ paintBrush.onclick = () => {
+ selectTool('paint');
+ };
+
+ eraseTool.onclick = () => {
+ selectTool('erase');
+ };
+
+ eyedropperTool.onclick = () => {
+ selectTool('eyedropper');
+ };
+
+ fillTool.onclick = () => {
+ selectTool('fill');
+ };
+
+ // Grid toggle
+ showGrid.onclick = () => {
+ editState.showGrid = !editState.showGrid;
+ showGrid.classList.toggle('active', editState.showGrid);
+ redrawCanvas();
+ };
+
+ // Brush size
+ brushSize.oninput = () => {
+ brushSizeValue.textContent = brushSize.value;
+ updateBrushSize(parseInt(brushSize.value));
+ };
+
+ // Undo/Redo
+ undoBtn.onclick = () => undoEdit();
+ redoBtn.onclick = () => redoEdit();
+
+ // Reset view
+ resetViewBtn.onclick = () => resetEditView();
+
+ // Enhanced zoom controls
+ editZoomIn.onclick = () => zoomIn();
+ editZoomOut.onclick = () => zoomOut();
+
+ zoomSelect.onchange = () => {
+ const newZoom = parseFloat(zoomSelect.value);
+ setZoom(newZoom);
+ };
+
+ zoomFit.onclick = () => fitToWindow();
+ zoom100.onclick = () => setZoom(1);
+
+ // Minimap navigation
+ if (minimapCanvas) {
+ minimapCanvas.onclick = (e) => navigateToMinimapPosition(e);
+ }
+ }
+
+ const ZOOM_LEVELS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 32];
+
+ let editState = {
+ currentTool: 'paint',
+ currentColor: '#000000',
+ currentColorId: null,
+ brushSize: 1,
+ zoom: 1,
+ panX: 0,
+ panY: 0,
+ undoStack: [],
+ redoStack: [],
+ isDrawing: false,
+ isPanning: false,
+ showGrid: false,
+
+ mouseX: 0,
+ mouseY: 0,
+ canvasWidth: 0,
+ canvasHeight: 0,
+ updatePending: false,
+ lastPaintPos: null,
+ touchStart: null,
+ lastTouchDistance: 0,
+ lastTouchCenter: { x: 0, y: 0 }
+ };
+
+ function calculateOptimalPanelSize(imageWidth, imageHeight) {
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ // Reserve minimal space for UI elements for almost fullscreen experience
+ const uiReserved = {
+ header: 80,
+ toolbar: 60,
+ bottomBar: 120,
+ padding: 40
+ };
+
+ const maxCanvasWidth = viewportWidth - uiReserved.padding;
+ const maxCanvasHeight = viewportHeight - uiReserved.header - uiReserved.toolbar - uiReserved.bottomBar - uiReserved.padding;
+
+ // Calculate optimal initial zoom to fit image
+ const scaleX = maxCanvasWidth / imageWidth;
+ const scaleY = maxCanvasHeight / imageHeight;
+ const initialZoom = Math.min(scaleX, scaleY, 1); // Don't zoom in initially
+
+ return {
+ panelWidth: Math.min(viewportWidth * 0.95, imageWidth * initialZoom + uiReserved.colorPanel + uiReserved.padding),
+ panelHeight: Math.min(viewportHeight * 0.95, imageHeight * initialZoom + uiReserved.toolbar + uiReserved.statusBar + uiReserved.canvasToolbar + uiReserved.padding),
+ canvasWidth: Math.min(maxCanvasWidth, imageWidth * initialZoom),
+ canvasHeight: Math.min(maxCanvasHeight, imageHeight * initialZoom),
+ initialZoom: Math.max(0.1, initialZoom)
+ };
+ }
+
+ function initializeEditPanel(imageData) {
+ const editCanvas = document.getElementById('editCanvas');
+ const ctx = editCanvas.getContext('2d');
+
+ // Reset edit state
+ editState.zoom = 1;
+ editState.panX = 0;
+ editState.panY = 0;
+ editState.undoStack = [];
+ editState.redoStack = [];
+ editState.currentTool = 'paint';
+ editState.brushSize = 1;
+ editState.showGrid = false;
+ editState.isPanning = false;
+ editState.isDrawing = false;
+ editState.lastPaintPos = null;
+
+ // Set canvas size to match baseCanvas
+ editCanvas.width = baseCanvas.width;
+ editCanvas.height = baseCanvas.height;
+ editState.canvasWidth = editCanvas.width;
+ editState.canvasHeight = editCanvas.height;
+
+ // Configure canvas context for pixel art
+ ctx.imageSmoothingEnabled = false;
+ ctx.webkitImageSmoothingEnabled = false;
+ ctx.mozImageSmoothingEnabled = false;
+ ctx.msImageSmoothingEnabled = false;
+
+ // Calculate optimal panel size
+ const panelSize = calculateOptimalPanelSize(editCanvas.width, editCanvas.height);
+ const editContainer = document.querySelector('.edit-container');
+ if (editContainer) {
+ editContainer.style.width = panelSize.panelWidth + 'px';
+ editContainer.style.height = panelSize.panelHeight + 'px';
+ }
+
+ // Setup canvas container
+ setupCanvasContainer();
+
+ // Add CSS checkerboard background to the wrapper element (not the canvas)
+ // This allows users to see transparency without saving it to the template
+ const canvasWrapper = document.getElementById('editCanvasWrapper');
+ if (canvasWrapper) {
+ canvasWrapper.style.background = `
+ repeating-conic-gradient(#f0f0f0 0% 25%, #e0e0e0 0% 50%)
+ 50% / 16px 16px
+ `;
+ }
+
+ // Load image onto canvas
+ const img = new Image();
+ img.onload = () => {
+ ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
+ // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
+ ctx.drawImage(img, 0, 0);
+
+ // Always fit artwork to the visible area on start and center
+ fitToWindow();
+ centerCanvas();
+
+ // Setup minimap
+ setupMinimap();
+
+ // Save initial state for undo
+ saveEditState();
+
+ // Set up canvas drawing events
+ setupCanvasDrawing();
+
+ // Initialize color palette
+ initializeEditColorPalette();
+
+ // Setup keyboard shortcuts
+ setupKeyboardShortcuts();
+
+ // Setup touch support
+ setupTouchSupport();
+
+ // Set initial tool
+ selectTool('paint');
+
+ // Update status bar
+ updateStatusBar(0, 0);
+
+ // Center canvas initially
+ centerCanvas();
+ };
+ img.src = imageData;
+ }
+
+ // mapClientToCanvas function for coordinate mapping
+ function mapClientToCanvas(clientX, clientY) {
+ const canvas = document.getElementById('editCanvas');
+ const wrapper = document.getElementById('editCanvasWrapper');
+ const container = document.getElementById('editCanvasContainer');
+
+ if (!canvas || !wrapper || !container) return null;
+
+ // Get the actual canvas element bounds (after CSS transform)
+ const canvasRect = canvas.getBoundingClientRect();
+
+ // Calculate relative position within the actual canvas bounds
+ const relativeX = (clientX - canvasRect.left) / canvasRect.width * canvas.width;
+ const relativeY = (clientY - canvasRect.top) / canvasRect.height * canvas.height;
+
+ // Convert to canvas coordinates
+ const canvasX = Math.floor(relativeX);
+ const canvasY = Math.floor(relativeY);
+
+ // Bounds checking
+ if (canvasX < 0 || canvasX >= canvas.width || canvasY < 0 || canvasY >= canvas.height) {
+ return null;
+ }
+
+ return { x: canvasX, y: canvasY };
+ }
+
+ function setupCanvasDrawing() {
+ const editCanvas = document.getElementById('editCanvas');
+ const ctx = editCanvas.getContext('2d');
+
+ let isDrawing = false;
+ let isPanning = false;
+ let lastX = 0;
+ let lastY = 0;
+ let panStartX = 0;
+ let panStartY = 0;
+
+ const getMousePos = (e) => {
+ return mapClientToCanvas(e.clientX, e.clientY);
+ };
+
+ editCanvas.onmousedown = (e) => {
+ if (e.button === 2 || e.ctrlKey) { // Right click or Ctrl+click for panning
+ editState.isPanning = true;
+ panStartX = e.clientX - editState.panX;
+ panStartY = e.clientY - editState.panY;
+ editCanvas.style.cursor = 'move';
+ e.preventDefault();
+ return;
+ }
+
+ const pos = getMousePos(e);
+ if (!pos) return;
+
+ if (editState.currentTool === 'eyedropper') {
+ handleEyedropper(pos.x, pos.y);
+ return;
+ }
+
+ if (editState.currentTool === 'fill') {
+ floodFill(pos.x, pos.y, editState.currentColor);
+ saveEditState();
+ return;
+ }
+
+ editState.isDrawing = true;
+ editState.lastPaintPos = { x: pos.x, y: pos.y };
+ lastX = pos.x;
+ lastY = pos.y;
+
+ paintAtPosition(pos.x, pos.y, true);
+ };
+
+ editCanvas.onmousemove = (e) => {
+ const pos = getMousePos(e);
+
+ if (pos) {
+ editState.mouseX = pos.x;
+ editState.mouseY = pos.y;
+ updateStatusBar(pos.x, pos.y);
+ }
+
+ if (editState.isPanning) {
+ editState.panX = e.clientX - panStartX;
+ editState.panY = e.clientY - panStartY;
+ constrainPan();
+ updateCanvasTransform();
+ updateMinimap();
+ return;
+ }
+
+ if (!editState.isDrawing) {
+ return;
+ }
+
+ if (pos && editState.lastPaintPos) {
+ paintAtPosition(pos.x, pos.y, true);
+ editState.lastPaintPos = { x: pos.x, y: pos.y };
+ }
+ };
+
+ editCanvas.onmouseup = (e) => {
+ if (editState.isPanning) {
+ editState.isPanning = false;
+ selectTool(editState.currentTool); // Restore cursor
+ return;
+ }
+
+ if (editState.isDrawing) {
+ editState.isDrawing = false;
+ editState.lastPaintPos = null;
+ saveEditState();
+ }
+ };
+
+ editCanvas.onmouseleave = () => {
+ if (editState.isPanning) {
+ editState.isPanning = false;
+ selectTool(editState.currentTool); // Restore cursor
+ }
+
+ if (editState.isDrawing) {
+ editState.isDrawing = false;
+ editState.lastPaintPos = null;
+ saveEditState();
+ }
+ };
+
+ // Prevent context menu on right click
+ editCanvas.oncontextmenu = (e) => {
+ e.preventDefault();
+ return false;
+ };
+
+ // Enhanced zoom with mouse wheel (zoom to cursor)
+ editCanvas.onwheel = (e) => {
+ e.preventDefault();
+ const zoomFactor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
+ zoomToPoint(editState.zoom * zoomFactor, e.clientX, e.clientY);
+ };
+ }
+
+ function paintAtPosition(x, y, isMouseDown = true) {
+ if (!isMouseDown || !editState.currentColor) return;
+
+ const canvas = document.getElementById('editCanvas');
+ const ctx = canvas.getContext('2d');
+
+ if (editState.lastPaintPos && editState.currentTool === 'paint') {
+ // Draw line from last position to current position
+ drawLine(ctx, editState.lastPaintPos.x, editState.lastPaintPos.y, x, y, editState.currentTool);
+ } else {
+ // Single brush stroke
+ drawBrush(ctx, x, y, editState.currentTool);
+ }
+
+ editState.lastPaintPos = { x, y };
+
+ // Update minimap thumbnail
+ if (!editState.updatePending) {
+ editState.updatePending = true;
+ requestAnimationFrame(() => {
+ setupMinimap();
+ editState.updatePending = false;
+ });
+ }
+ }
+
+ function drawBrush(ctx, x, y, tool) {
+ const size = editState.brushSize;
+ const halfSize = Math.floor(size / 2);
+
+ if (tool === 'paint') {
+ ctx.fillStyle = editState.currentColor;
+ for (let dx = 0; dx < size; dx++) {
+ for (let dy = 0; dy < size; dy++) {
+ // Center the brush at the cursor position
+ const px = x - halfSize + dx;
+ const py = y - halfSize + dy;
+
+ if (px >= 0 && px < ctx.canvas.width && py >= 0 && py < ctx.canvas.height) {
+ ctx.fillRect(px, py, 1, 1);
+ }
+ }
+ }
+ } else if (tool === 'erase') {
+ for (let dx = 0; dx < size; dx++) {
+ for (let dy = 0; dy < size; dy++) {
+ // Center the brush at the cursor position
+ const px = x - halfSize + dx;
+ const py = y - halfSize + dy;
+
+ if (px >= 0 && px < ctx.canvas.width && py >= 0 && py < ctx.canvas.height) {
+ ctx.clearRect(px, py, 1, 1);
+ }
+ }
+ }
+ }
+ }
+
+ function drawPixel(x, y) {
+ drawBrush(document.getElementById('editCanvas').getContext('2d'), x, y, 'paint');
+ }
+
+ function erasePixel(x, y) {
+ const editCanvas = document.getElementById('editCanvas');
+ const ctx = editCanvas.getContext('2d');
+
+ const size = editState.brushSize;
+ const halfSize = Math.floor(size / 2);
+
+ for (let dx = 0; dx < size; dx++) {
+ for (let dy = 0; dy < size; dy++) {
+ // Center the brush at the cursor position (consistent with drawBrush)
+ const px = x - halfSize + dx;
+ const py = y - halfSize + dy;
+
+ if (px >= 0 && px < editCanvas.width && py >= 0 && py < editCanvas.height) {
+ ctx.clearRect(px, py, 1, 1);
+ }
+ }
+ }
+ }
+
+ function drawLine(ctx, x1, y1, x2, y2, tool) {
+ // Bresenham's line algorithm with tool support
+ const dx = Math.abs(x2 - x1);
+ const dy = Math.abs(y2 - y1);
+ const sx = x1 < x2 ? 1 : -1;
+ const sy = y1 < y2 ? 1 : -1;
+ let err = dx - dy;
+
+ let x = x1;
+ let y = y1;
+
+ while (true) {
+ drawBrush(ctx, x, y, tool);
+
+ if (x === x2 && y === y2) break;
+
+ const e2 = 2 * err;
+ if (e2 > -dy) {
+ err -= dy;
+ x += sx;
+ }
+ if (e2 < dx) {
+ err += dx;
+ y += sy;
+ }
+ }
+ }
+
+ function eraseLine(x1, y1, x2, y2) {
+ // Same as drawLine but with erase
+ const dx = Math.abs(x2 - x1);
+ const dy = Math.abs(y2 - y1);
+ const sx = x1 < x2 ? 1 : -1;
+ const sy = y1 < y2 ? 1 : -1;
+ let err = dx - dy;
+
+ let x = x1;
+ let y = y1;
+
+ while (true) {
+ erasePixel(x, y);
+
+ if (x === x2 && y === y2) break;
+
+ const e2 = 2 * err;
+ if (e2 > -dy) {
+ err -= dy;
+ x += sx;
+ }
+ if (e2 < dx) {
+ err += dx;
+ y += sy;
+ }
+ }
+ }
+
+ function selectTool(tool) {
+ editState.currentTool = tool;
+
+ document.querySelectorAll('.edit-tool').forEach(btn => {
+ btn.classList.remove('active');
+ });
+
+ const editCanvas = document.getElementById('editCanvas');
+
+ switch (tool) {
+ case 'paint':
+ document.getElementById('paintBrush').classList.add('active');
+ editCanvas.style.cursor = 'crosshair';
+ break;
+ case 'erase':
+ document.getElementById('eraseTool').classList.add('active');
+ editCanvas.style.cursor = 'not-allowed';
+ break;
+ case 'eyedropper':
+ document.getElementById('eyedropperTool').classList.add('active');
+ editCanvas.style.cursor = 'copy';
+ break;
+ case 'fill':
+ document.getElementById('fillTool').classList.add('active');
+ editCanvas.style.cursor = 'pointer';
+ break;
+ }
+ }
+
+ function updateBrushSize(size) {
+ editState.brushSize = size;
+ }
+
+ function initializeEditColorPalette() {
+ const colorGrid = document.getElementById('editColorGrid');
+ const currentColorDisplay = document.getElementById('currentColorDisplay');
+
+ colorGrid.innerHTML = '';
+
+ let availableColors = [];
+
+ // Try to get colors from state first
+ if (state && state.availableColors && state.availableColors.length > 0) {
+ availableColors = state.availableColors.map(color => ({
+ id: color.id,
+ name: color.name,
+ rgb: color.rgb,
+ hex: `#${color.rgb[0].toString(16).padStart(2, '0')}${color.rgb[1].toString(16).padStart(2, '0')}${color.rgb[2].toString(16).padStart(2, '0')}`
+ }));
+ } else {
+ // Fallback to CONFIG.COLOR_MAP
+ availableColors = Object.values(CONFIG.COLOR_MAP)
+ .filter(color => color.rgb !== null)
+ .map(color => ({
+ id: color.id,
+ name: color.name,
+ rgb: [color.rgb.r, color.rgb.g, color.rgb.b],
+ hex: `#${color.rgb.r.toString(16).padStart(2, '0')}${color.rgb.g.toString(16).padStart(2, '0')}${color.rgb.b.toString(16).padStart(2, '0')}`
+ }));
+ }
+
+ // Fallback to basic colors if nothing available
+ if (availableColors.length === 0) {
+ availableColors = [
+ { id: 0, name: 'Black', rgb: [0, 0, 0], hex: '#000000' },
+ { id: 1, name: 'White', rgb: [255, 255, 255], hex: '#ffffff' },
+ { id: 2, name: 'Red', rgb: [255, 0, 0], hex: '#ff0000' },
+ { id: 3, name: 'Green', rgb: [0, 255, 0], hex: '#00ff00' },
+ { id: 4, name: 'Blue', rgb: [0, 0, 255], hex: '#0000ff' }
+ ];
+ }
+
+ // Update color count
+ const colorCount = document.getElementById('editColorCount');
+ if (colorCount) {
+ colorCount.textContent = availableColors.length;
+ }
+
+ availableColors.forEach(color => {
+ const colorBtn = document.createElement('button');
+ colorBtn.className = 'color-btn';
+ colorBtn.style.backgroundColor = color.hex;
+ colorBtn.title = `${color.name} (${color.hex})`;
+ colorBtn.dataset.colorId = color.id;
+ colorBtn.dataset.colorHex = color.hex;
+
+ colorBtn.onclick = () => {
+ editState.currentColor = color.hex;
+ editState.currentColorId = color.id;
+ currentColorDisplay.style.backgroundColor = color.hex;
+
+ // Update active color
+ document.querySelectorAll('.color-btn').forEach(btn => {
+ btn.classList.remove('selected');
+ });
+ colorBtn.classList.add('selected');
+
+ updateStatusBar(editState.mouseX, editState.mouseY);
+ };
+
+ colorGrid.appendChild(colorBtn);
+ });
+
+ // Set first color as default
+ if (availableColors.length > 0) {
+ editState.currentColor = availableColors[0].hex;
+ editState.currentColorId = availableColors[0].id;
+ currentColorDisplay.style.backgroundColor = availableColors[0].hex;
+ colorGrid.firstChild.classList.add('selected');
+ }
+ }
+
+ function saveEditState() {
+ const editCanvas = document.getElementById('editCanvas');
+ const imageData = editCanvas.toDataURL();
+
+ editState.undoStack.push(imageData);
+
+ // Limit undo stack size
+ if (editState.undoStack.length > 50) {
+ editState.undoStack.shift();
+ }
+
+ // Clear redo stack when new action is performed
+ editState.redoStack = [];
+
+ updateUndoRedoButtons();
+ }
+
+ function undoEdit() {
+ if (editState.undoStack.length <= 1) return;
+
+ const currentState = editState.undoStack.pop();
+ editState.redoStack.push(currentState);
+
+ const previousState = editState.undoStack[editState.undoStack.length - 1];
+ loadEditState(previousState);
+
+ updateUndoRedoButtons();
+ }
+
+ function redoEdit() {
+ if (editState.redoStack.length === 0) return;
+
+ const nextState = editState.redoStack.pop();
+ editState.undoStack.push(nextState);
+
+ loadEditState(nextState);
+
+ updateUndoRedoButtons();
+ }
+
+ function loadEditState(imageData) {
+ const editCanvas = document.getElementById('editCanvas');
+ const ctx = editCanvas.getContext('2d');
+
+ const img = new Image();
+ img.onload = () => {
+ ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
+ // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
+ ctx.drawImage(img, 0, 0);
+ };
+ img.src = imageData;
+ }
+
+ function updateUndoRedoButtons() {
+ const undoBtn = document.getElementById('undoBtn');
+ const redoBtn = document.getElementById('redoBtn');
+
+ if (undoBtn) {
+ undoBtn.disabled = editState.undoStack.length <= 1;
+ }
+
+ if (redoBtn) {
+ redoBtn.disabled = editState.redoStack.length === 0;
+ }
+ }
+
+ function updateCanvasTransform() {
+ const wrapper = document.getElementById('editCanvasWrapper');
+ if (wrapper) {
+ wrapper.style.transform = `translate(${editState.panX}px, ${editState.panY}px) scale(${editState.zoom})`;
+ }
+
+ // Update zoom select
+ const zoomSelect = document.getElementById('zoomSelect');
+ if (zoomSelect) {
+ // If exact value exists, use it, otherwise show nearest but update displayed text
+ const exact = ZOOM_LEVELS.find(z => z === editState.zoom);
+ zoomSelect.value = exact != null ? exact : ZOOM_LEVELS.reduce((prev, curr) => Math.abs(curr - editState.zoom) < Math.abs(prev - editState.zoom) ? curr : prev);
+ // Also update status bar zoom text
+ updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
+ }
+ }
+
+ function setupCanvasContainer() {
+ const canvasContainer = document.getElementById('editCanvasContainer');
+ const editCanvas = document.getElementById('editCanvas');
+ const wrapper = document.getElementById('editCanvasWrapper');
+
+ if (!canvasContainer || !wrapper) return;
+
+ // Setup container styles
+ canvasContainer.style.cssText = `
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background:
+ linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
+ linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
+ linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
+ background-size: 20px 20px;
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
+ background-color: #ddd;
+ `;
+
+ // Setup wrapper styles
+ wrapper.style.cssText = `
+ position: relative;
+ transform-origin: center center;
+ display: inline-block;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
+ border: 2px solid #333;
+ background: white;
+ `;
+ }
+
+ function zoomToPoint(newZoom, clientX, clientY) {
+ const container = document.getElementById('editCanvasContainer');
+ const wrapper = document.getElementById('editCanvasWrapper');
+
+ if (!container || !wrapper) return;
+
+ newZoom = Math.max(0.1, Math.min(32, newZoom));
+
+ if (clientX !== undefined && clientY !== undefined) {
+ // Get current transform values
+ const oldZoom = editState.zoom;
+ const rect = container.getBoundingClientRect();
+
+ // Calculate zoom center point relative to container
+ const containerCenterX = rect.left + rect.width / 2;
+ const containerCenterY = rect.top + rect.height / 2;
+
+ // Calculate offset to keep zoom point centered
+ const offsetX = (clientX - containerCenterX) * (1 - newZoom / oldZoom);
+ const offsetY = (clientY - containerCenterY) * (1 - newZoom / oldZoom);
+
+ editState.panX += offsetX;
+ editState.panY += offsetY;
+ }
+
+ editState.zoom = newZoom;
+ constrainPan();
+ updateCanvasTransform();
+ updateMinimap();
+ updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
+ }
+
+ function constrainPan() {
+ const container = document.getElementById('editCanvasContainer');
+ const canvas = document.getElementById('editCanvas');
+
+ if (!container || !canvas) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const scaledWidth = canvas.width * editState.zoom;
+ const scaledHeight = canvas.height * editState.zoom;
+
+ // Small padding around edges to avoid snapping against borders
+ const padding = 10;
+
+ // Calculate limits to keep canvas somewhat visible
+ const maxPanX = Math.max(0, (scaledWidth - containerRect.width) / 2 + padding);
+ const maxPanY = Math.max(0, (scaledHeight - containerRect.height) / 2 + padding);
+
+ editState.panX = Math.max(-maxPanX, Math.min(maxPanX, editState.panX));
+ editState.panY = Math.max(-maxPanY, Math.min(maxPanY, editState.panY));
+ }
+
+ function setZoom(zoom) {
+ zoomToPoint(zoom);
+ }
+
+ function zoomIn() {
+ const currentIndex = ZOOM_LEVELS.findIndex(z => z >= editState.zoom);
+ const nextIndex = Math.min(currentIndex + 1, ZOOM_LEVELS.length - 1);
+ setZoom(ZOOM_LEVELS[nextIndex]);
+ }
+
+ function zoomOut() {
+ const currentIndex = ZOOM_LEVELS.findIndex(z => z >= editState.zoom);
+ const prevIndex = Math.max(currentIndex - 1, 0);
+ setZoom(ZOOM_LEVELS[prevIndex]);
+ }
+
+ function fitToWindow() {
+ const container = document.getElementById('editCanvasContainer');
+ const canvas = document.getElementById('editCanvas');
+
+ if (!container || !canvas) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const padding = 40;
+
+ const scaleX = (containerRect.width - padding) / canvas.width;
+ const scaleY = (containerRect.height - padding) / canvas.height;
+ const fitZoom = Math.max(0.1, Math.min(scaleX, scaleY));
+
+ // Center the canvas
+ editState.zoom = fitZoom;
+ editState.panX = 0;
+ editState.panY = 0;
+ updateCanvasTransform();
+ updateMinimap();
+ updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
+ }
+
+ function centerCanvas() {
+ editState.panX = 0;
+ editState.panY = 0;
+ updateCanvasTransform();
+ updateMinimap();
+ }
+
+ function resetEditView() {
+ editState.zoom = 1;
+ editState.panX = 0;
+ editState.panY = 0;
+
+ updateCanvasTransform();
+ updateMinimap();
+ }
+
+ function setupMinimap() {
+ const minimapCanvas = document.getElementById('minimapCanvas');
+ const editCanvas = document.getElementById('editCanvas');
+ const minimapContainer = document.getElementById('minimapContainer');
+
+ if (!minimapCanvas || !editCanvas) return;
+
+ // Show minimap only for larger images
+ if (editCanvas.width < 100 || editCanvas.height < 100) {
+ if (minimapContainer) minimapContainer.style.display = 'none';
+ return;
+ }
+
+ // Calculate minimap size
+ const maxSize = 150;
+ const scale = Math.min(maxSize / editCanvas.width, maxSize / editCanvas.height);
+
+ minimapCanvas.width = editCanvas.width * scale;
+ minimapCanvas.height = editCanvas.height * scale;
+
+ // Draw thumbnail
+ const minimapCtx = minimapCanvas.getContext('2d');
+ minimapCtx.imageSmoothingEnabled = false;
+ // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
+ minimapCtx.drawImage(editCanvas, 0, 0, minimapCanvas.width, minimapCanvas.height);
+
+ // Update viewport indicator
+ updateMinimap();
+ }
+
+ function updateMinimap() {
+ const viewport = document.getElementById('minimapViewport');
+ const minimapCanvas = document.getElementById('minimapCanvas');
+ const container = document.getElementById('editCanvasContainer');
+ const editCanvas = document.getElementById('editCanvas');
+
+ if (!viewport || !minimapCanvas || !container || !editCanvas) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const scale = minimapCanvas.width / editCanvas.width;
+
+ // Calculate visible area in minimap coordinates
+ const visibleWidth = Math.min(containerRect.width / editState.zoom * scale, minimapCanvas.width);
+ const visibleHeight = Math.min(containerRect.height / editState.zoom * scale, minimapCanvas.height);
+
+ const viewportX = (minimapCanvas.width / 2) - (editState.panX / editState.zoom * scale) - (visibleWidth / 2);
+ const viewportY = (minimapCanvas.height / 2) - (editState.panY / editState.zoom * scale) - (visibleHeight / 2);
+
+ viewport.style.width = `${visibleWidth}px`;
+ viewport.style.height = `${visibleHeight}px`;
+ viewport.style.left = `${Math.max(0, Math.min(minimapCanvas.width - visibleWidth, viewportX))}px`;
+ viewport.style.top = `${Math.max(0, Math.min(minimapCanvas.height - visibleHeight, viewportY))}px`;
+ }
+
+ function navigateToMinimapPosition(e) {
+ const minimapCanvas = document.getElementById('minimapCanvas');
+ const editCanvas = document.getElementById('editCanvas');
+
+ if (!minimapCanvas || !editCanvas) return;
+
+ const rect = minimapCanvas.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / minimapCanvas.width;
+ const y = (e.clientY - rect.top) / minimapCanvas.height;
+
+ // Convert to canvas coordinates and center
+ const targetX = (x - 0.5) * editCanvas.width * editState.zoom;
+ const targetY = (y - 0.5) * editCanvas.height * editState.zoom;
+
+ editState.panX = -targetX;
+ editState.panY = -targetY;
+
+ constrainPan();
+ updateCanvasTransform();
+ updateMinimap();
+ }
+
+ function updateStatusBar(x, y) {
+ const statusBar = document.getElementById('editStatusBar');
+ if (statusBar) {
+ const zoomPercent = Math.round(editState.zoom * 100);
+ statusBar.textContent = `Position: (${x}, ${y}) | Color: ${editState.currentColor || '#000000'} | Zoom: ${zoomPercent}%`;
+ }
+ }
+
+ function handleEyedropper(x, y) {
+ const editCanvas = document.getElementById('editCanvas');
+ const ctx = editCanvas.getContext('2d');
+
+ if (x >= 0 && x < editCanvas.width && y >= 0 && y < editCanvas.height) {
+ const imageData = ctx.getImageData(x, y, 1, 1);
+ const [r, g, b] = imageData.data;
+ const pickedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
+
+ editState.currentColor = pickedColor;
+ const currentColorDisplay = document.getElementById('currentColorDisplay');
+ if (currentColorDisplay) {
+ currentColorDisplay.style.backgroundColor = pickedColor;
+ }
+
+ // Try to find matching color in palette
+ document.querySelectorAll('.edit-color-btn').forEach(btn => {
+ btn.classList.remove('active');
+ if (btn.dataset.colorHex === pickedColor) {
+ btn.classList.add('active');
+ editState.currentColorId = parseInt(btn.dataset.colorId);
+ }
+ });
+
+ updateStatusBar(x, y);
+ }
+ }
+
+ function floodFill(startX, startY, fillColor) {
+ const editCanvas = document.getElementById('editCanvas');
+ const ctx = editCanvas.getContext('2d');
+ const imageData = ctx.getImageData(0, 0, editCanvas.width, editCanvas.height);
+ const data = imageData.data;
+
+ if (startX < 0 || startX >= editCanvas.width || startY < 0 || startY >= editCanvas.height) return;
+
+ const startIndex = (startY * editCanvas.width + startX) * 4;
+ const startR = data[startIndex];
+ const startG = data[startIndex + 1];
+ const startB = data[startIndex + 2];
+ const startA = data[startIndex + 3];
+
+ // Convert fill color to RGB
+ const fillR = parseInt(fillColor.slice(1, 3), 16);
+ const fillG = parseInt(fillColor.slice(3, 5), 16);
+ const fillB = parseInt(fillColor.slice(5, 7), 16);
+
+ // Don't fill if the color is already the same
+ if (startR === fillR && startG === fillG && startB === fillB) return;
+
+ const pixelsToCheck = [{ x: startX, y: startY }];
+ const checkedPixels = new Set();
+
+ while (pixelsToCheck.length > 0) {
+ const { x, y } = pixelsToCheck.pop();
+ const key = `${x},${y}`;
+
+ if (checkedPixels.has(key)) continue;
+ checkedPixels.add(key);
+
+ if (x < 0 || x >= editCanvas.width || y < 0 || y >= editCanvas.height) continue;
+
+ const index = (y * editCanvas.width + x) * 4;
+ const r = data[index];
+ const g = data[index + 1];
+ const b = data[index + 2];
+ const a = data[index + 3];
+
+ if (r === startR && g === startG && b === startB && a === startA) {
+ data[index] = fillR;
+ data[index + 1] = fillG;
+ data[index + 2] = fillB;
+ data[index + 3] = 255;
+
+ pixelsToCheck.push({ x: x + 1, y });
+ pixelsToCheck.push({ x: x - 1, y });
+ pixelsToCheck.push({ x, y: y + 1 });
+ pixelsToCheck.push({ x, y: y - 1 });
+ }
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+ }
+
+ // Brush preview removed per user request
+ function hideBrushPreview() {
+ // No-op - brush preview disabled
+ }
+
+ function drawGrid(ctx, width, height) {
+ if (!editState.showGrid || editState.zoom < 4) return;
+
+ ctx.save();
+ ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)';
+ ctx.lineWidth = 1 / editState.zoom;
+
+ for (let x = 0; x <= width; x++) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ }
+
+ for (let y = 0; y <= height; y++) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+
+ ctx.restore();
+ }
+
+ function drawCheckerboardBackground(ctx, width, height) {
+ const checkerSize = 8; // Size of each checker square
+ ctx.fillStyle = '#f0f0f0'; // Light gray
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.fillStyle = '#e0e0e0'; // Slightly darker gray
+ for (let x = 0; x < width; x += checkerSize) {
+ for (let y = 0; y < height; y += checkerSize) {
+ if ((Math.floor(x / checkerSize) + Math.floor(y / checkerSize)) % 2 === 1) {
+ ctx.fillRect(x, y, checkerSize, checkerSize);
+ }
+ }
+ }
+ }
+
+ function redrawCanvas() {
+ if (editState.undoStack.length === 0) return;
+
+ const currentState = editState.undoStack[editState.undoStack.length - 1];
+ const editCanvas = document.getElementById('editCanvas');
+ const ctx = editCanvas.getContext('2d');
+
+ const img = new Image();
+ img.onload = () => {
+ ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
+ // REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
+ ctx.drawImage(img, 0, 0);
+ drawGrid(ctx, editCanvas.width, editCanvas.height);
+ };
+ img.src = currentState;
+ }
+
+ function setupTouchSupport() {
+ const canvas = document.getElementById('editCanvas');
+ if (!canvas) return;
+
+ let touchStartTime = 0;
+
+ canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
+ canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
+ canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
+
+ function handleTouchStart(e) {
+ e.preventDefault();
+ touchStartTime = Date.now();
+
+ if (e.touches.length === 1) {
+ // Single touch - start painting
+ const touch = e.touches[0];
+ const coords = mapClientToCanvas(touch.clientX, touch.clientY);
+ if (coords) {
+ editState.isDrawing = true;
+ editState.lastPaintPos = { x: coords.x, y: coords.y };
+ paintAtPosition(coords.x, coords.y, true);
+ }
+ } else if (e.touches.length === 2) {
+ // Two finger - prepare for zoom/pan
+ const touch1 = e.touches[0];
+ const touch2 = e.touches[1];
+
+ editState.lastTouchDistance = Math.hypot(
+ touch2.clientX - touch1.clientX,
+ touch2.clientY - touch1.clientY
+ );
+
+ editState.lastTouchCenter = {
+ x: (touch1.clientX + touch2.clientX) / 2,
+ y: (touch1.clientY + touch2.clientY) / 2
+ };
+ }
+ }
+
+ function handleTouchMove(e) {
+ e.preventDefault();
+
+ if (e.touches.length === 1 && editState.isDrawing) {
+ // Continue painting
+ const touch = e.touches[0];
+ const coords = mapClientToCanvas(touch.clientX, touch.clientY);
+ if (coords) {
+ paintAtPosition(coords.x, coords.y, true);
+ }
+ } else if (e.touches.length === 2) {
+ // Pinch zoom and pan
+ const touch1 = e.touches[0];
+ const touch2 = e.touches[1];
+
+ const currentDistance = Math.hypot(
+ touch2.clientX - touch1.clientX,
+ touch2.clientY - touch1.clientY
+ );
+
+ const currentCenter = {
+ x: (touch1.clientX + touch2.clientX) / 2,
+ y: (touch1.clientY + touch2.clientY) / 2
+ };
+
+ // Zoom based on distance change
+ if (editState.lastTouchDistance > 0) {
+ const zoomFactor = currentDistance / editState.lastTouchDistance;
+ const newZoom = Math.max(0.1, Math.min(32, editState.zoom * zoomFactor));
+ zoomToPoint(newZoom, currentCenter.x, currentCenter.y);
+ }
+
+ // Pan based on center movement
+ editState.panX += currentCenter.x - editState.lastTouchCenter.x;
+ editState.panY += currentCenter.y - editState.lastTouchCenter.y;
+
+ editState.lastTouchDistance = currentDistance;
+ editState.lastTouchCenter = currentCenter;
+
+ constrainPan();
+ updateCanvasTransform();
+ updateMinimap();
+ }
+ }
+
+ function handleTouchEnd(e) {
+ e.preventDefault();
+
+ if (editState.isDrawing) {
+ editState.isDrawing = false;
+ editState.lastPaintPos = null;
+ saveEditState();
+ }
+
+ if (e.touches.length === 0) {
+ editState.lastTouchDistance = 0;
+ }
+ }
+ }
+
+ function setupKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ // Only handle shortcuts when edit panel is visible
+ const editOverlay = document.getElementById('editOverlay');
+ if (!editOverlay || editOverlay.style.display === 'none') return;
+
+ // Prevent default for handled keys
+ const handledKeys = ['b', 'e', 'i', 'f', 'g', 'z', '[', ']'];
+ if (handledKeys.includes(e.key.toLowerCase()) || (e.ctrlKey && e.key.toLowerCase() === 'z')) {
+ e.preventDefault();
+ }
+
+ switch (e.key.toLowerCase()) {
+ case 'b': // Brush
+ selectTool('paint');
+ break;
+ case 'e': // Erase
+ selectTool('erase');
+ break;
+ case 'i': // Eyedropper
+ selectTool('eyedropper');
+ break;
+ case 'f': // Fill
+ selectTool('fill');
+ break;
+ case 'g': // Grid
+ const showGrid = document.getElementById('showGrid');
+ if (showGrid) showGrid.click();
+ break;
+ case 'z':
+ if (e.ctrlKey || e.metaKey) {
+ if (e.shiftKey) {
+ redoEdit();
+ } else {
+ undoEdit();
+ }
+ }
+ break;
+ case '=':
+ case '+':
+ if (e.ctrlKey || e.metaKey) {
+ zoomIn();
+ }
+ break;
+ case '-':
+ if (e.ctrlKey || e.metaKey) {
+ zoomOut();
+ }
+ break;
+ case '0':
+ if (e.ctrlKey || e.metaKey) {
+ fitToWindow();
+ }
+ break;
+ case '1':
+ if (e.ctrlKey || e.metaKey) {
+ setZoom(1);
+ }
+ break;
+ case '[': // Decrease brush size
+ const brushSize = document.getElementById('brushSize');
+ const currentSize = parseInt(brushSize.value);
+ if (currentSize > 1) {
+ brushSize.value = currentSize - 1;
+ document.getElementById('brushSizeValue').textContent = currentSize - 1;
+ updateBrushSize(currentSize - 1);
+ }
+ break;
+ case ']': // Increase brush size
+ const brushSize2 = document.getElementById('brushSize');
+ const currentSize2 = parseInt(brushSize2.value);
+ if (currentSize2 < 20) {
+ brushSize2.value = currentSize2 + 1;
+ document.getElementById('brushSizeValue').textContent = currentSize2 + 1;
+ updateBrushSize(currentSize2 + 1);
+ }
+ break;
+ }
+ });
+ }
+
+ function applyEditChanges() {
+ try {
+ const editCanvas = document.getElementById('editCanvas');
+ if (!editCanvas) {
+ throw new Error('Edit canvas not found');
+ }
+
+ // Find the resize canvas in the resize panel
+ const resizeCanvas = document.getElementById('resizeCanvas');
+ if (!resizeCanvas) {
+ throw new Error('Resize canvas not found');
+ }
+
+ const baseCtx = resizeCanvas.getContext('2d');
+ if (!baseCtx) {
+ throw new Error('Resize canvas context not available');
+ }
+
+ // Make sure the resize canvas has the same dimensions as the edit canvas
+ if (resizeCanvas.width !== editCanvas.width || resizeCanvas.height !== editCanvas.height) {
+ resizeCanvas.width = editCanvas.width;
+ resizeCanvas.height = editCanvas.height;
+ }
+
+ // Clear resize canvas
+ baseCtx.clearRect(0, 0, resizeCanvas.width, resizeCanvas.height);
+
+ // Copy edited image to resize canvas (no checkerboard since we removed it entirely)
+ baseCtx.imageSmoothingEnabled = false;
+ baseCtx.drawImage(editCanvas, 0, 0);
+
+ // CRITICAL: Completely replace the template system with edited artwork
+ const editedImageData = editCanvas.toDataURL();
+
+ // Create new processor with edited image as the template
+ if (window.WPlaceImageProcessor) {
+ const newProcessor = new window.WPlaceImageProcessor(editedImageData);
+ newProcessor.load().then(() => {
+ // Get the processed pixel data from the edited canvas
+ const editCtx = editCanvas.getContext('2d');
+ const editImageData = editCtx.getImageData(0, 0, editCanvas.width, editCanvas.height);
+ const pixels = editImageData.data;
+
+ // Count valid pixels in the edited image
+ let totalValidPixels = 0;
+ for (let i = 0; i < pixels.length; i += 4) {
+ const a = pixels[i + 3];
+ const r = pixels[i];
+ const g = pixels[i + 1];
+ const b = pixels[i + 2];
+
+ const isTransparent = !state.paintTransparentPixels && a < state.customTransparencyThreshold;
+ const isWhiteAndSkipped = !state.paintWhitePixels && Utils.isWhitePixel(r, g, b);
+
+ if (!isTransparent && !isWhiteAndSkipped) {
+ totalValidPixels++;
+ }
+ }
+
+ // COMPLETELY REBUILD state.imageData with the edited artwork
+ state.imageData = {
+ width: editCanvas.width,
+ height: editCanvas.height,
+ pixels: pixels,
+ totalPixels: totalValidPixels,
+ processor: newProcessor,
+ };
+
+ // CRITICAL: Update state.originalImage so resize panel uses edited artwork as base template
+ state.originalImage = {
+ dataUrl: editedImageData,
+ width: editCanvas.width,
+ height: editCanvas.height
+ };
+
+ // Update state with new totals
+ state.totalPixels = totalValidPixels;
+ state.paintedPixels = 0; // Reset progress since this is a new template
+ state.currentPaintingColor = null; // Reset color tracking
+ state.imageLoaded = true;
+
+ // Update local processors
+ if (typeof processor !== 'undefined') {
+ processor = newProcessor;
+ console.log('š Updated local processor with edited artwork');
+ }
+ if (typeof baseProcessor !== 'undefined') {
+ baseProcessor = newProcessor;
+ console.log('š Updated baseProcessor with edited artwork');
+ }
+
+ // Force regeneration of overlays by clearing cached mask data
+ if (typeof window._maskImageData !== 'undefined') {
+ delete window._maskImageData;
+ }
+
+ // Update UI to reflect the new template
+ if (typeof updateUI === 'function') {
+ updateUI();
+ }
+
+ // Show loading and properly reload resize panel with new template
+ Utils.showAlert('Updating template... Please wait.', 'info');
+
+ // Give more time for template to fully update and force resize panel reload
+ setTimeout(() => {
+ // Hide edit overlay first
+ document.getElementById('editOverlay').style.display = 'none';
+
+ // Completely reload resize dialog with updated processor
+ setTimeout(() => {
+ // Clean up existing dialog
+ if (typeof _resizeDialogCleanup === 'function') {
+ _resizeDialogCleanup();
+ }
+
+ // Force complete reload of resize dialog with new processor
+ setTimeout(() => {
+ showResizeDialog(newProcessor);
+
+ console.log('ā
Template COMPLETELY replaced with edited artwork');
+ console.log(`š New template stats: ${editCanvas.width}x${editCanvas.height}, ${totalValidPixels} pixels`);
+ Utils.showAlert('Template successfully replaced with your edited artwork!', 'success');
+ }, 100);
+ }, 300);
+ }, 100);
+ }).catch((error) => {
+ console.error('Failed to load new processor:', error);
+ Utils.showAlert('Failed to update template. Please try again.', 'error');
+ });
+ } else {
+ console.error('WPlaceImageProcessor not available');
+ Utils.showAlert('Image processor not available. Please reload the page.', 'error');
+ }
+
+ console.log('ā
Edit changes applied - template replacement in progress');
+ } catch (error) {
+ console.error('Error applying edit changes:', error);
+ Utils.showAlert('Failed to apply changes. Please try again.', 'error');
+ }
+ }
+
+ if (uploadBtn) {
+ uploadBtn.addEventListener('click', async () => {
+ const availableColors = Utils.extractAvailableColors();
+ if (availableColors === null || availableColors.length < 10) {
+ updateUI('noColorsFound', 'error');
+ Utils.showAlert(Utils.t('noColorsFound'), 'error');
+ return;
+ }
+
+ if (!state.colorsChecked) {
+ state.availableColors = availableColors;
+ state.colorsChecked = true;
+ updateUI('colorsFound', 'success', { count: availableColors.length });
+ await updateStats();
+ selectPosBtn.disabled = false;
+ // Only enable resize button if image is also loaded
+ if (state.imageLoaded) {
+ resizeBtn.disabled = false;
+ }
+ }
+
+ try {
+ updateUI('loadingImage', 'default');
+ const imageSrc = await Utils.createImageUploader();
+ if (!imageSrc) {
+ updateUI('colorsFound', 'success', {
+ count: state.availableColors.length,
+ });
+ return;
+ }
+
+ const processor = new ImageProcessor(imageSrc);
+ await processor.load();
+
+ const { width, height } = processor.getDimensions();
+ const pixels = processor.getPixelData();
+
+ let totalValidPixels = 0;
+ for (let i = 0; i < pixels.length; i += 4) {
+ const isTransparent =
+ !state.paintTransparentPixels &&
+ pixels[i + 3] < (state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD);
+ const isWhiteAndSkipped =
+ !state.paintWhitePixels &&
+ Utils.isWhitePixel(pixels[i], pixels[i + 1], pixels[i + 2]);
+ if (!isTransparent && !isWhiteAndSkipped) {
+ totalValidPixels++;
+ }
+ }
+
+ state.imageData = {
+ width,
+ height,
+ pixels,
+ totalPixels: totalValidPixels,
+ processor,
+ };
+
+ state.totalPixels = totalValidPixels;
+ state.paintedPixels = 0;
+ state.currentPaintingColor = null; // Reset color tracking
+ state.imageLoaded = true;
+
+ // Reset session-specific flags when a new image is loaded
+ state.preFilteringDone = false;
+ state.progressResetDone = false;
+ console.log('š Reset session flags for new image load');
+
+ // Keep existing lastPosition to continue from where we left off
+ // state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
+
+ // Initialize painted map for tracking
+ Utils.initializePaintedMap(width, height);
+
+ // New image: clear previous resize settings
+ state.resizeSettings = null;
+ // Also clear any previous ignore mask
+ state.resizeIgnoreMask = null;
+ // Save original image for this browser (dataUrl + dims)
+ state.originalImage = { dataUrl: imageSrc, width, height };
+ saveBotSettings();
+
+ // Use the original image for the overlay initially
+ const imageBitmap = await createImageBitmap(processor.img);
+ await overlayManager.setImage(imageBitmap);
+ overlayManager.enable();
+ toggleOverlayBtn.disabled = false;
+ toggleOverlayBtn.classList.add('active');
+ toggleOverlayBtn.setAttribute('aria-pressed', 'true');
+
+ // Only enable resize button if colors have also been captured
+ if (state.colorsChecked) {
+ resizeBtn.disabled = false;
+ }
+ saveBtn.disabled = false;
+
+ if (state.startPosition) {
+ startBtn.disabled = false;
+ }
+
+ await updateStats();
+ updateDataButtons();
+ updateUI('imageLoaded', 'success', { count: totalValidPixels });
+ } catch {
+ updateUI('imageError', 'error');
+ }
+ });
+ }
+
+ // Load Extracted button event listener - for loading Art-Extractor JSON files
+ const loadExtractedBtn = document.getElementById('loadExtractedBtn');
+ if (loadExtractedBtn) {
+ loadExtractedBtn.addEventListener('click', async () => {
+ try {
+ updateUI('loadingImage', 'default');
+ const fileData = await Utils.loadExtractedFileData();
+
+ if (!fileData) {
+ updateUI('ready', 'default');
+ return;
+ }
+
+ console.log('š Loading extracted artwork from Art-Extractor...');
+ console.log('š [DEBUG] Loaded file data structure:', {
+ hasState: !!fileData.state,
+ hasImageData: !!fileData.imageData,
+ topLevelKeys: Object.keys(fileData),
+ stateKeys: fileData.state ? Object.keys(fileData.state) : 'N/A',
+ imageDataKeys: fileData.imageData ? Object.keys(fileData.imageData) : 'N/A',
+ fileDataType: typeof fileData,
+ stateType: typeof fileData.state,
+ imageDataType: typeof fileData.imageData
+ });
+
+ // Validate the data structure before restoring
+ if (!fileData || typeof fileData !== 'object') {
+ throw new Error('Invalid file format: File data is not a valid object');
+ }
+
+ if (!fileData.state || typeof fileData.state !== 'object') {
+ console.error('ā State validation failed. FileData:', fileData);
+ throw new Error('Invalid file format: Missing or invalid state object. Please ensure you exported from Art-Extractor correctly.');
+ }
+
+ if (!fileData.imageData || typeof fileData.imageData !== 'object') {
+ console.error('ā ImageData validation failed. FileData:', fileData);
+ throw new Error('Invalid file format: Missing or invalid imageData object. Please ensure you completed the area scan in Art-Extractor.');
+ }
+
+ if (!fileData.imageData.pixels || !Array.isArray(fileData.imageData.pixels) || fileData.imageData.pixels.length === 0) {
+ console.error('ā Pixel data validation failed. ImageData:', fileData.imageData);
+ throw new Error('Invalid file format: No pixel data found. Please scan an area in Art-Extractor before exporting.');
+ }
+
+ // Ensure critical fields are present for Art-Extractor compatibility
+ if (!fileData.state.availableColors) {
+ console.warn('ā ļø No availableColors in file, using defaults');
+ fileData.state.availableColors = [];
+ }
+
+ // Reset session-specific flags when loading extracted file data
+ state.preFilteringDone = false;
+ state.progressResetDone = false;
+ console.log('š Reset session flags for extracted file load');
+
+ // Use the existing restoreProgress function but with special handling
+ const restoreSuccess = await Utils.restoreProgress(fileData);
+
+ if (!restoreSuccess) {
+ throw new Error('Failed to restore progress data');
+ }
+
+ // After loading, we need to enter position selection mode like when uploading images
+ if (state.imageLoaded) {
+ console.log('šÆ Extracted artwork loaded, entering position selection mode...');
+
+ // Clear position data since Art-Extractor exports may have null positions for manual placement
+ state.startPosition = null;
+ state.region = null;
+ state.selectingPosition = true;
+
+ // For extracted files, force enable all necessary flags for full functionality
+ state.colorsChecked = true;
+ state.currentPaintingColor = null; // Reset color tracking
+ state.imageLoaded = true;
+
+ // Ensure image processor is available for extracted files
+ if (state.imageData && !state.imageData.processor) {
+ try {
+ // Create a temporary canvas to generate a data URL for the processor
+ const canvas = document.createElement('canvas');
+ canvas.width = state.imageData.width;
+ canvas.height = state.imageData.height;
+ const ctx = canvas.getContext('2d');
+
+ // Create image data from pixels
+ const imageData = new ImageData(
+ new Uint8ClampedArray(state.imageData.pixels),
+ state.imageData.width,
+ state.imageData.height
+ );
+ ctx.putImageData(imageData, 0, 0);
+
+ // Create processor with canvas data URL using the correct class
+ const dataUrl = canvas.toDataURL();
+ state.imageData.processor = new window.WPlaceImageProcessor(dataUrl);
+
+ // CRITICAL: Load the processor before using it
+ await state.imageData.processor.load();
+ console.log('š§ Created and loaded WPlaceImageProcessor for extracted artwork');
+ } catch (error) {
+ console.error('ā Failed to create image processor for extracted artwork:', error);
+ // Fallback: use global processor if available
+ if (window.globalImageProcessor) {
+ console.log('š Using global image processor as fallback');
+ state.imageData.processor = window.globalImageProcessor;
+ }
+ }
+ }
+
+ console.log('šØ Force-enabled all flags for extracted JSON to access resize panel');
+
+ // For extracted images, ensure overlay is enabled but don't set position yet
+ try {
+ if (overlayManager && state.imageData) {
+ // Create image bitmap from the restored image data
+ const canvas = new OffscreenCanvas(state.imageData.width, state.imageData.height);
+ const ctx = canvas.getContext('2d');
+ const imageData = new ImageData(
+ new Uint8ClampedArray(state.imageData.pixels),
+ state.imageData.width,
+ state.imageData.height
+ );
+ ctx.putImageData(imageData, 0, 0);
+ const imageBitmap = await canvas.transferToImageBitmap();
+
+ await overlayManager.setImage(imageBitmap);
+ overlayManager.enable();
+ console.log('ā
Overlay enabled for extracted artwork');
+ }
+ } catch (overlayError) {
+ console.warn('ā ļø Could not set overlay for extracted artwork:', overlayError);
+ }
+
+ // For extracted images, don't try to set overlay position until user selects one
+ // The overlay should already be enabled from the restoreProgress function
+
+ // Update UI with custom message for extracted artwork
+ Utils.showAlert('šØ Extracted artwork loaded! Please click on the canvas to select where to place it.', 'info');
+ updateUI('ready', 'default'); // Use 'ready' instead of 'waitingPosition' to avoid translation issues
+
+ // Enable relevant buttons
+ selectPosBtn.disabled = false;
+ // Always enable resize button for extracted files since they have image processor
+ resizeBtn.disabled = false;
+ console.log('š§ Resize button enabled for extracted artwork');
+
+ // Enable move artwork button (will be fully enabled after position is set)
+ const moveArtworkBtn = document.getElementById('moveArtworkBtn');
+ if (moveArtworkBtn) {
+ moveArtworkBtn.disabled = false;
+ }
+
+ // Set up position selection like the regular selectPosBtn
+ const tempFetch = async (url, options) => {
+ if (
+ typeof url === 'string' &&
+ url.includes('/s0/pixel/') &&
+ options &&
+ options.method === 'POST'
+ ) {
+ let coords;
+ try {
+ const body = JSON.parse(options.body);
+ coords = body.coords;
+ } catch (e) {
+ return window.originalFetch(url, options);
+ }
+
+ const tileMatches = url.match(/\/s0\/pixel\/(\-?\d+)\/(\-?\d+)/);
+ if (tileMatches && coords && coords.length >= 2) {
+ const tileX = parseInt(tileMatches[1]);
+ const tileY = parseInt(tileMatches[2]);
+ const pixelX = coords[0];
+ const pixelY = coords[1];
+
+ state.startPosition = { x: pixelX, y: pixelY };
+ state.region = { x: tileX, y: tileY };
+ state.selectingPosition = false;
+
+ console.log('šÆ Position selected for extracted artwork:', {
+ startPosition: state.startPosition,
+ region: state.region
+ });
+
+ // Restore original fetch
+ window.fetch = window.originalFetch;
+
+ // Update overlay position with validation
+ try {
+ if (state.startPosition && state.region && overlayManager) {
+ await overlayManager.setPosition(state.startPosition, state.region);
+ console.log('ā
Overlay position updated successfully');
+ } else {
+ console.warn('ā ļø Cannot set overlay position: missing startPosition or region');
+ }
+ } catch (positionError) {
+ console.error('ā Failed to set overlay position:', positionError);
+ }
+
+ startBtn.disabled = false;
+ selectPosBtn.textContent = Utils.t('selectPosition');
+
+ // Enable Move Artwork button when position is set
+ const moveArtworkBtn = document.getElementById('moveArtworkBtn');
+ if (moveArtworkBtn) {
+ moveArtworkBtn.disabled = false;
+ }
+
+ updateUI('ready', 'success');
+ Utils.showAlert('Position selected! Ready to start painting.', 'success');
+ }
+ }
+ return window.originalFetch(url, options);
+ };
+
+ // Store original fetch and replace with position capture
+ if (!window.originalFetch) {
+ window.originalFetch = window.fetch;
+ }
+ window.fetch = tempFetch;
+ }
+
+ } catch (error) {
+ console.error('ā Failed to load extracted artwork:', error);
+ Utils.showAlert(`Failed to load extracted artwork: ${error.message}`, 'error');
+ updateUI('ready', 'default');
+ }
+ });
+ }
+
+ if (resizeBtn) {
+ resizeBtn.addEventListener('click', () => {
+ console.log('š [DEBUG] Resize button clicked. State check:', {
+ imageLoaded: state.imageLoaded,
+ hasProcessor: !!(state.imageData && state.imageData.processor),
+ colorsChecked: state.colorsChecked,
+ hasAvailableColors: !!(state.availableColors && state.availableColors.length > 0)
+ });
+
+ if (state.imageLoaded && state.imageData && state.imageData.processor) {
+ showResizeDialog(state.imageData.processor);
+ } else {
+ let message = 'Please upload an image and check colors first.';
+ if (!state.imageLoaded) message = 'Please upload an image first.';
+ else if (!state.imageData || !state.imageData.processor) message = 'Image processor not available. Please reload the image.';
+ Utils.showAlert(message, 'warning');
+ }
+ });
+ }
+
+ // Move Artwork button event listener
+ const moveArtworkBtn = document.getElementById('moveArtworkBtn');
+ if (moveArtworkBtn) {
+ moveArtworkBtn.addEventListener('click', () => {
+ if (state.imageLoaded && (state.startPosition || state.selectingPosition)) {
+ showMoveArtworkPanel();
+ } else if (!state.imageLoaded) {
+ Utils.showAlert('Please upload or load an image first', 'warning');
+ } else {
+ Utils.showAlert('Please select a position for the artwork first', 'warning');
+ }
+ });
+ }
+
+ if (selectPosBtn) {
+ selectPosBtn.addEventListener('click', async () => {
+ if (state.selectingPosition) return;
+
+ state.selectingPosition = true;
+ state.startPosition = null;
+ state.region = null;
+ startBtn.disabled = true;
+
+ // Disable Move Artwork button when selecting new position
+ const moveArtworkBtn = document.getElementById('moveArtworkBtn');
+ if (moveArtworkBtn) {
+ moveArtworkBtn.disabled = true;
+ }
+
+ Utils.showAlert(Utils.t('selectPositionAlert'), 'info');
+ updateUI('waitingPosition', 'default');
+
+ const tempFetch = async (url, options) => {
+ if (
+ typeof url === 'string' &&
+ url.includes('https://backend.wplace.live/s0/pixel/') &&
+ options?.method?.toUpperCase() === 'POST'
+ ) {
+ try {
+ const response = await originalFetch(url, options);
+ const clonedResponse = response.clone();
+ const data = await clonedResponse.json();
+
+ if (data?.painted === 1) {
+ const regionMatch = url.match(/\/pixel\/(\d+)\/(\d+)/);
+ if (regionMatch && regionMatch.length >= 3) {
+ state.region = {
+ x: Number.parseInt(regionMatch[1]),
+ y: Number.parseInt(regionMatch[2]),
+ };
+ }
+
+ const payload = JSON.parse(options.body);
+ if (payload?.coords && Array.isArray(payload.coords)) {
+ state.startPosition = {
+ x: payload.coords[0],
+ y: payload.coords[1],
+ };
+ // Keep existing lastPosition to continue from where we left off
+ // state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
+
+ // Update overlay position with validation
+ try {
+ if (state.startPosition && state.region && overlayManager) {
+ await overlayManager.setPosition(state.startPosition, state.region);
+ console.log('ā
Regular overlay position updated successfully');
+ } else {
+ console.warn('ā ļø Cannot set regular overlay position: missing startPosition or region');
+ }
+ } catch (positionError) {
+ console.error('ā Failed to set regular overlay position:', positionError);
+ }
+
+ if (state.imageLoaded) {
+ startBtn.disabled = false;
+
+ // Enable Move Artwork button when position is set
+ const moveArtworkBtn = document.getElementById('moveArtworkBtn');
+ if (moveArtworkBtn) {
+ moveArtworkBtn.disabled = false;
+ }
+ }
+
+ window.fetch = originalFetch;
+ state.selectingPosition = false;
+ updateUI('positionSet', 'success');
+ }
+ }
+
+ return response;
+ } catch {
+ return originalFetch(url, options);
+ }
+ }
+ return originalFetch(url, options);
+ };
+
+ const originalFetch = window.fetch;
+ window.fetch = tempFetch;
+
+ setTimeout(() => {
+ if (state.selectingPosition) {
+ window.fetch = originalFetch;
+ state.selectingPosition = false;
+ updateUI('positionTimeout', 'error');
+ Utils.showAlert(Utils.t('positionTimeout'), 'error');
+ }
+ }, 120000);
+ });
+ }
+
+ async function startPainting() {
+ if (!state.imageLoaded || !state.startPosition || !state.region) {
+ updateUI('missingRequirements', 'error');
+ return;
+ }
+ await ensureToken();
+ if (!getTurnstileToken()) return;
+
+ // Only perform progressive pixel detection on first start of session
+ if (!state.preFilteringDone) {
+ // Perform progressive pixel detection from top-left to bottom-right
+ console.log('š Starting progressive pixel detection from top-left to bottom-right...');
+ await performProgressivePixelDetection();
+
+ // Mark pre-filtering as done to prevent duplicate scanning
+ state.preFilteringDone = true;
+ console.log('ā
Pre-filtering marked as complete - will not scan again this session');
+ } else {
+ console.log('š Continuing session - pre-filtering already done');
+ }
+
+ state.running = true;
+ state.stopFlag = false;
+ startBtn.disabled = true;
+ stopBtn.disabled = false;
+ uploadBtn.disabled = true;
+ selectPosBtn.disabled = true;
+ resizeBtn.disabled = true;
+ saveBtn.disabled = true;
+ toggleOverlayBtn.disabled = true;
+
+ updateUI('startPaintingMsg', 'success');
+
+ try {
+ await getAccounts();
+ await processImage();
+ } catch (e) {
+ console.error('Unexpected error:', e);
+ updateUI('paintingError', 'error');
+ } finally {
+ state.running = false;
+ stopBtn.disabled = true;
+ saveBtn.disabled = false;
+
+ if (state.stopFlag) {
+ startBtn.disabled = false;
+ } else {
+ startBtn.disabled = true;
+ uploadBtn.disabled = false;
+ const loadExtractedBtn = document.getElementById('loadExtractedBtn');
+ if (loadExtractedBtn) loadExtractedBtn.disabled = false;
+ selectPosBtn.disabled = false;
+ resizeBtn.disabled = false;
+ }
+ toggleOverlayBtn.disabled = false;
+ }
+ }
+
+ if (startBtn) {
+ startBtn.addEventListener('click', startPainting);
+ }
+
+ if (stopBtn) {
+ stopBtn.addEventListener('click', () => {
+ state.stopFlag = true;
+ state.running = false;
+ stopBtn.disabled = true;
+ updateUI('paintingStoppedByUser', 'warning');
+
+ if (state.imageLoaded && state.paintedPixels > 0) {
+ Utils.saveProgress();
+ Utils.showAlert(Utils.t('autoSaved'), 'success');
+ }
+ });
+ }
+
+ const checkSavedProgress = () => {
+ const savedData = Utils.loadProgress();
+ if (savedData && savedData.state.paintedPixels > 0) {
+ const savedDate = new Date(savedData.timestamp).toLocaleString();
+ const progress = Math.round(
+ (savedData.state.paintedPixels / savedData.state.totalPixels) * 100
+ );
+
+ Utils.showAlert(
+ `${Utils.t('savedDataFound')}\n\n` +
+ `Saved: ${savedDate}\n` +
+ `Progress: ${savedData.state.paintedPixels}/${savedData.state.totalPixels} pixels (${progress}%)\n` +
+ `${Utils.t('clickLoadToContinue')}`,
+ 'info'
+ );
+ }
+ };
+
+ setTimeout(checkSavedProgress, 1000);
+
+ if (cooldownSlider && cooldownInput && cooldownValue && cooldownDecrease && cooldownIncrease) {
+ const updateCooldown = (newValue) => {
+ const threshold = Math.max(1, Math.min(state.maxCharges || 999, parseInt(newValue)));
+ state.cooldownChargeThreshold = threshold;
+
+ // Update both controls (value shows in input, label shows unit only)
+ cooldownSlider.value = threshold;
+ cooldownInput.value = threshold;
+ cooldownValue.textContent = `${Utils.t('charges')}`;
+
+ saveBotSettings();
+ NotificationManager.resetEdgeTracking(); // prevent spurious notify after threshold change
+ };
+
+ // Slider event listener
+ cooldownSlider.addEventListener('input', (e) => {
+ updateCooldown(e.target.value);
+ });
+
+ // Number input event listener
+ cooldownInput.addEventListener('input', (e) => {
+ updateCooldown(e.target.value);
+ });
+
+ // Decrease button
+ cooldownDecrease.addEventListener('click', () => {
+ updateCooldown(parseInt(cooldownInput.value) - 1);
+ });
+
+ // Increase button
+ cooldownIncrease.addEventListener('click', () => {
+ updateCooldown(parseInt(cooldownInput.value) + 1);
+ });
+
+ // Add scroll-to-adjust for cooldown slider
+ Utils.createScrollToAdjust(cooldownSlider, updateCooldown, 1, state.maxCharges, 1);
+
+ // Skip cooldown button event handler
+ const skipCooldownBtn = document.getElementById('skipCooldownBtn');
+ if (skipCooldownBtn) {
+ skipCooldownBtn.addEventListener('click', () => {
+ if (state.preciseCurrentCharges < state.cooldownChargeThreshold) {
+ console.log(`[Auto-Image] Skip cooldown requested - resetting to account index 0`);
+
+ // Reset to account index 0 (start new cycle)
+ state.currentActiveIndex = 0;
+ console.log(`š Reset currentActiveIndex to 0 for new cycle`);
+
+ // Reset charges to threshold to bypass cooldown
+ state.preciseCurrentCharges = state.cooldownChargeThreshold;
+ console.log(`[Auto-Image] Cooldown skipped! Charges set to threshold: ${state.cooldownChargeThreshold}`);
+
+ // Switch to first account if we have multiple accounts
+ if (accountManager.getAccountCount() > 1) {
+ const firstAccountInfo = accountManager.getAccountByIndex(0);
+ if (firstAccountInfo && firstAccountInfo.token) {
+ console.log(`š Switching to first account: index 0 (${firstAccountInfo.displayName})`);
+ // Get accounts array for the switch
+ const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
+ if (accounts.length > 0) {
+ switchToSpecificAccount(firstAccountInfo.token, firstAccountInfo.displayName).then(() => {
+ console.log(`ā
Successfully switched to account index 0 after cooldown skip`);
+ // Update UI immediately
+ updateChargesThresholdUI(0);
+ // Trigger immediate check for painting
+ if (state.isEnabled && !state.stopFlag) {
+ setTimeout(() => {
+ checkAndPaint();
+ }, 100);
+ }
+ }).catch(err => {
+ console.error(`ā Failed to switch to account index 0:`, err);
+ });
+ } else {
+ console.warn(`ā ļø No accounts available for switching`);
+ }
+ } else {
+ console.warn(`ā ļø First account info not found or missing token`);
+ }
+ } else {
+ console.log(`š Single account mode - no switching needed`);
+ // Update UI immediately
+ updateChargesThresholdUI(0);
+ // Trigger immediate check for painting
+ if (state.isEnabled && !state.stopFlag) {
+ setTimeout(() => {
+ checkAndPaint();
+ }, 100);
+ }
+ }
+ }
+ });
+ }
+ }
+
+ loadBotSettings();
+ // Ensure notification poller reflects current settings
+ NotificationManager.syncFromState();
+ }
+
+ function getMsToTargetCharges(current, target, cooldown, intervalMs = 0) {
+ const remainingCharges = target - current;
+ return Math.max(0, remainingCharges * cooldown - intervalMs);
+ }
+
+ function updateChargesThresholdUI(intervalMs) {
+ if (state.stopFlag) return;
+
+ const threshold = state.cooldownChargeThreshold;
+ const remainingMs = getMsToTargetCharges(
+ state.preciseCurrentCharges,
+ threshold,
+ state.cooldown,
+ intervalMs
+ );
+ const timeText = Utils.msToTimeText(remainingMs);
+
+ updateUI(
+ 'noChargesThreshold',
+ 'warning',
+ {
+ threshold,
+ current: state.displayCharges,
+ time: timeText,
+ },
+ true
+ );
+
+ // Update skip cooldown button state
+ const skipCooldownBtn = document.getElementById('skipCooldownBtn');
+ if (skipCooldownBtn) {
+ const isInCooldown = state.preciseCurrentCharges < threshold && remainingMs > 0;
+ skipCooldownBtn.disabled = !isInCooldown;
+ skipCooldownBtn.title = isInCooldown
+ ? `Skip cooldown (${timeText} remaining)`
+ : 'Skip cooldown (only available during cooldown)';
+ }
+ }
+
+ // Fast tile-based pixel detection from top-left to bottom-right
+ async function performProgressivePixelDetection() {
+ if (!state.imageLoaded || !state.imageData) {
+ console.log('ā ļø No image loaded, skipping pixel detection');
+ return;
+ }
+
+ const startTime = performance.now();
+ const { width, height } = state.imageData;
+ const startX = state.startPosition.x;
+ const startY = state.startPosition.y;
+ const regionX = state.region.x;
+ const regionY = state.region.y;
+
+ let detectedPixels = 0;
+ let totalChecked = 0;
+
+ console.log(`š Fast scanning ${width}x${height} image from top-left (0,0) to bottom-right (${width - 1},${height - 1})...`);
+
+ updateUI('pixelDetection', 'info', { message: 'Fast detecting already painted pixels...' });
+
+ // Calculate affected tiles
+ const worldX1 = startX;
+ const worldY1 = startY;
+ const worldX2 = startX + width - 1;
+ const worldY2 = startY + height - 1;
+
+ const startTileX = Math.floor(worldX1 / 1000);
+ const startTileY = Math.floor(worldY1 / 1000);
+ const endTileX = Math.floor(worldX2 / 1000);
+ const endTileY = Math.floor(worldY2 / 1000);
+
+ console.log(`š Processing tiles from (${startTileX},${startTileY}) to (${endTileX},${endTileY})`);
+
+ // Cache for downloaded tile data
+ const tileDataCache = new Map();
+
+ // Download and cache all required tiles in parallel
+ const tilePromises = [];
+ for (let tileY = startTileY; tileY <= endTileY; tileY++) {
+ for (let tileX = startTileX; tileX <= endTileX; tileX++) {
+ const absoluteTileX = regionX + tileX;
+ const absoluteTileY = regionY + tileY;
+ const tileKey = `${absoluteTileX},${absoluteTileY}`;
+
+ tilePromises.push(
+ downloadTileImageData(absoluteTileX, absoluteTileY).then(imageData => {
+ if (imageData) {
+ tileDataCache.set(tileKey, imageData);
+ }
+ }).catch(e => {
+ console.warn(`ā ļø Failed to download tile ${absoluteTileX},${absoluteTileY}:`, e.message);
+ })
+ );
+ }
+ }
+
+ // Wait for all tiles to download
+ await Promise.all(tilePromises);
+ console.log(`š¦ Downloaded ${tileDataCache.size} tiles for fast pixel checking`);
+
+ // Fast pixel detection using cached tile data
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ totalChecked++;
+
+ // Check if pixel is eligible for painting
+ const targetPixelInfo = checkPixelEligibility(x, y);
+ if (!targetPixelInfo.eligible) {
+ continue; // Skip non-eligible pixels
+ }
+
+ // Calculate absolute world coordinates
+ const absX = startX + x;
+ const absY = startY + y;
+ const adderX = Math.floor(absX / 1000);
+ const adderY = Math.floor(absY / 1000);
+ const pixelX = absX % 1000;
+ const pixelY = absY % 1000;
+ const absoluteTileX = regionX + adderX;
+ const absoluteTileY = regionY + adderY;
+
+ // Check if already marked as painted in local map
+ if (Utils.isPixelPainted(x, y, absoluteTileX, absoluteTileY)) {
+ detectedPixels++;
+ continue;
+ }
+
+ // Fast pixel color check using cached tile data
+ const tileKey = `${absoluteTileX},${absoluteTileY}`;
+ const tileImageData = tileDataCache.get(tileKey);
+
+ if (tileImageData) {
+ try {
+ // Direct array access - much faster than canvas operations
+ const tileWidth = tileImageData.width;
+ const tileHeight = tileImageData.height;
+ const data = tileImageData.data;
+
+ // Ensure pixel coordinates are within tile bounds
+ if (pixelX >= 0 && pixelX < tileWidth && pixelY >= 0 && pixelY < tileHeight) {
+ const pixelIndex = (pixelY * tileWidth + pixelX) * 4;
+ const r = data[pixelIndex];
+ const g = data[pixelIndex + 1];
+ const b = data[pixelIndex + 2];
+ const a = data[pixelIndex + 3];
+
+ // Check alpha threshold
+ const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
+ if (a >= alphaThresh) {
+ const existingMappedColor = Utils.resolveColor(
+ [r, g, b],
+ state.availableColors,
+ !state.paintUnavailablePixels
+ );
+ const isAlreadyPainted = existingMappedColor.id === targetPixelInfo.mappedColorId;
+
+ if (isAlreadyPainted) {
+ // Check if pixel is already marked as painted to avoid double counting
+ if (!Utils.isPixelPainted(x, y, absoluteTileX, absoluteTileY)) {
+ // Mark as painted in the map but DO NOT increment progress counter
+ // Progress counter should only reflect actual painting sequence position
+ Utils.markPixelPainted(x, y, absoluteTileX, absoluteTileY);
+ detectedPixels++;
+ } else {
+ // Pixel already tracked, just count it for detection stats
+ detectedPixels++;
+ }
+ }
+ }
+ }
+ } catch (e) {
+ // Skip pixels we can't check
+ console.warn(`ā ļø Could not check pixel (${x}, ${y}):`, e.message);
+ }
+ }
+
+ // Update progress periodically
+ if (totalChecked % 2500 === 0) {
+ const progress = Math.round((totalChecked / (width * height)) * 100);
+ updateUI('pixelDetection', 'info', {
+ message: `Fast detecting... ${progress}% (Found: ${detectedPixels})`
+ });
+ // Yield control briefly to prevent UI blocking
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ }
+ }
+
+ const processingTime = Math.round(performance.now() - startTime);
+ console.log(`š Fast pixel detection complete in ${processingTime}ms:`);
+ console.log(` - Total pixels checked: ${totalChecked}`);
+ console.log(` - Already painted pixels found: ${detectedPixels}`);
+ console.log(` - Updated progress: ${state.paintedPixels}/${state.totalPixels}`);
+ console.log(` - Performance: ${Math.round(totalChecked / (processingTime / 1000))} pixels/second`);
+
+ // Update progress display
+ await updateStats();
+ updateUI('pixelDetectionComplete', 'success', {
+ message: `Found ${detectedPixels} already painted pixels in ${processingTime}ms`
+ });
+ }
+
+ // Fast tile download and ImageData extraction (similar to Art-Extractor approach)
+ async function downloadTileImageData(tileX, tileY) {
+ try {
+ const tileUrl = `https://backend.wplace.live/files/s0/tiles/${tileX}/${tileY}.png`;
+ const response = await fetch(tileUrl);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ return await processTileBlob(blob);
+ } catch (error) {
+ console.warn(`Failed to download tile ${tileX},${tileY}:`, error.message);
+ return null;
+ }
+ }
+
+ // Process tile blob into ImageData (adapted from Art-Extractor)
+ async function processTileBlob(blob) {
+ try {
+ const img = new Image();
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ return new Promise((resolve, reject) => {
+ img.onload = () => {
+ canvas.width = img.width;
+ canvas.height = img.height;
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(img, 0, 0);
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ resolve(imageData);
+ };
+ img.onerror = () => reject(new Error('Failed to load image'));
+ img.src = URL.createObjectURL(blob);
+ });
+ } catch (error) {
+ console.error('Error processing tile blob:', error);
+ return null;
+ }
+ }
+
+ function generateCoordinates(width, height, mode, direction, snake, blockWidth, blockHeight, startFromX = 0, startFromY = 0) {
+ const coords = [];
+ console.log(
+ 'Generating coordinates with \n mode:',
+ mode,
+ '\n direction:',
+ direction,
+ '\n snake:',
+ snake,
+ '\n blockWidth:',
+ blockWidth,
+ '\n blockHeight:',
+ blockHeight,
+ '\n startFromX:',
+ startFromX,
+ '\n startFromY:',
+ startFromY
+ );
+ // --------- Standard 4 corners traversal ----------
+ let xStart, xEnd, xStep;
+ let yStart, yEnd, yStep;
+ switch (direction) {
+ case 'top-left':
+ xStart = 0;
+ xEnd = width;
+ xStep = 1;
+ yStart = 0;
+ yEnd = height;
+ yStep = 1;
+ break;
+ case 'top-right':
+ xStart = width - 1;
+ xEnd = -1;
+ xStep = -1;
+ yStart = 0;
+ yEnd = height;
+ yStep = 1;
+ break;
+ case 'bottom-left':
+ xStart = 0;
+ xEnd = width;
+ xStep = 1;
+ yStart = height - 1;
+ yEnd = -1;
+ yStep = -1;
+ break;
+ case 'bottom-right':
+ xStart = width - 1;
+ xEnd = -1;
+ xStep = -1;
+ yStart = height - 1;
+ yEnd = -1;
+ yStep = -1;
+ break;
+ default:
+ throw new Error(`Unknown direction: ${direction}`);
+ }
+
+ // --------- Traversal modes ----------
+ if (mode === 'rows') {
+ let rowIndex = 0;
+ for (let y = yStart; y !== yEnd; y += yStep) {
+ if (snake && rowIndex % 2 !== 0) {
+ for (let x = xEnd - xStep; x !== xStart - xStep; x -= xStep) {
+ coords.push([x, y]);
+ }
+ } else {
+ for (let x = xStart; x !== xEnd; x += xStep) {
+ coords.push([x, y]);
+ }
+ }
+ rowIndex++;
+ }
+ } else if (mode === 'columns') {
+ let colIndex = 0;
+ for (let x = xStart; x !== xEnd; x += xStep) {
+ if (snake && colIndex % 2 !== 0) {
+ for (let y = yEnd - yStep; y !== yStart - yStep; y -= yStep) {
+ coords.push([x, y]);
+ }
+ } else {
+ for (let y = yStart; y !== yEnd; y += yStep) {
+ coords.push([x, y]);
+ }
+ }
+ colIndex++;
+ }
+ } else if (mode === 'circle-out') {
+ const cx = Math.floor(width / 2);
+ const cy = Math.floor(height / 2);
+ const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
+
+ for (let r = 0; r <= maxRadius; r++) {
+ for (let y = cy - r; y <= cy + r; y++) {
+ for (let x = cx - r; x <= cx + r; x++) {
+ if (x >= 0 && x < width && y >= 0 && y < height) {
+ const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
+ if (dist === r) coords.push([x, y]);
+ }
+ }
+ }
+ }
+ } else if (mode === 'circle-in') {
+ const cx = Math.floor(width / 2);
+ const cy = Math.floor(height / 2);
+ const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
+
+ for (let r = maxRadius; r >= 0; r--) {
+ for (let y = cy - r; y <= cy + r; y++) {
+ for (let x = cx - r; x <= cx + r; x++) {
+ if (x >= 0 && x < width && y >= 0 && y < height) {
+ const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
+ if (dist === r) coords.push([x, y]);
+ }
+ }
+ }
+ }
+ } else if (mode === 'blocks' || mode === 'shuffle-blocks') {
+ const blocks = [];
+ for (let by = 0; by < height; by += blockHeight) {
+ for (let bx = 0; bx < width; bx += blockWidth) {
+ const block = [];
+ for (let y = by; y < Math.min(by + blockHeight, height); y++) {
+ for (let x = bx; x < Math.min(bx + blockWidth, width); x++) {
+ block.push([x, y]);
+ }
+ }
+ blocks.push(block);
+ }
+ }
+
+ if (mode === 'shuffle-blocks') {
+ // Simple Fisher-Yates shuffle
+ for (let i = blocks.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [blocks[i], blocks[j]] = [blocks[j], blocks[i]];
+ }
+ }
+
+ // Append all blocks without spread/concat to avoid large allocations and argument explosion
+ for (const block of blocks) {
+ for (let i = 0; i < block.length; i++) {
+ coords.push(block[i]);
+ }
+ }
+ } else {
+ throw new Error(`Unknown mode: ${mode}`);
+ }
+
+ // Filter coordinates to start from the specified position
+ if (startFromX > 0 || startFromY > 0) {
+ console.log(`š Filtering coordinates to resume from position (${startFromX}, ${startFromY})`);
+ let startIndex = -1;
+
+ // Find the starting position in the coordinate list
+ for (let i = 0; i < coords.length; i++) {
+ const [x, y] = coords[i];
+ if (x === startFromX && y === startFromY) {
+ startIndex = i;
+ break;
+ }
+ }
+
+ if (startIndex >= 0) {
+ // Resume from the found position (skip all previous coordinates)
+ const filteredCoords = coords.slice(startIndex);
+ console.log(`āļø Resuming: skipped ${startIndex} coordinates, continuing with ${filteredCoords.length} remaining`);
+ return filteredCoords;
+ } else {
+ console.warn(`ā ļø Resume position (${startFromX}, ${startFromY}) not found in coordinate list, starting from beginning`);
+ }
+ }
+
+ return coords;
+ }
+
+ async function flushPixelBatch(pixelBatch) {
+ if (!pixelBatch || pixelBatch.pixels.length === 0) return true;
+
+ const batchSize = pixelBatch.pixels.length;
+ console.log(
+ `š¦ Sending batch with ${batchSize} pixels (region: ${pixelBatch.regionX},${pixelBatch.regionY})`
+ );
+ const success = await sendBatchWithRetry(
+ pixelBatch.pixels,
+ pixelBatch.regionX,
+ pixelBatch.regionY
+ );
+ if (success) {
+ // Only increment progress for actually painted pixels to prevent multiplication
+ const actuallyPaintedCount = pixelBatch.pixels.length;
+ state.paintedPixels += actuallyPaintedCount;
+ console.log(`š Added ${actuallyPaintedCount} painted pixels to progress (total: ${state.paintedPixels})`);
+
+ pixelBatch.pixels.forEach((p) => {
+ Utils.markPixelPainted(p.x, p.y, pixelBatch.regionX, pixelBatch.regionY);
+ });
+
+ // Update last painted position to the last pixel in the successful batch
+ if (pixelBatch.pixels.length > 0) {
+ const lastPixel = pixelBatch.pixels[pixelBatch.pixels.length - 1];
+ // FIXED: Use localX/localY (image-relative coordinates) instead of x/y (absolute canvas coordinates)
+ state.lastPaintedPosition = { x: lastPixel.localX, y: lastPixel.localY };
+ }
+
+ // IMPORTANT: Decrement charges locally to match Acc-Switch.js behavior
+ state.displayCharges = Math.max(0, state.displayCharges - batchSize);
+ state.preciseCurrentCharges = Math.max(0, state.preciseCurrentCharges - batchSize);
+
+ state.fullChargeData = {
+ ...state.fullChargeData,
+ spentSinceShot: state.fullChargeData.spentSinceShot + batchSize,
+ };
+ await updateStats();
+ // Update account list with new charges
+ await updateCurrentAccountInList();
+ // Progress tracking removed from UI to reduce visual clutter
+ Utils.performSmartSave();
+
+ if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && batchSize > 0) {
+ const delayPerPixel = 1000 / state.paintingSpeed;
+ const totalDelay = Math.max(100, delayPerPixel * batchSize);
+ await Utils.sleep(totalDelay);
+ }
+ } else {
+ console.error(`ā Batch failed permanently after retries. Stopping painting.`);
+ state.stopFlag = true;
+ updateUI('paintingBatchFailed', 'error');
+ }
+
+ pixelBatch.pixels = [];
+ return success;
+ }
+
+ async function processImage() {
+ console.log('š Starting auto-swap enabled painting workflow');
+
+ try {
+ // Main painting cycle - repeats until image complete or stopped
+ while (!state.stopFlag) {
+ console.log('š Phase 1: Starting painting session');
+ const paintingResult = await executePaintingSession();
+
+ if (paintingResult === 'completed') {
+ console.log('š Image painting completed!');
+ state.currentPaintingColor = null; // Reset color tracking
+ break;
+ }
+
+ if (paintingResult === 'stopped') {
+ console.log('ā¹ļø Painting stopped by user');
+ break;
+ }
+
+ if (paintingResult === 'charges_depleted') {
+ if (CONFIG.autoBuyToggle && CONFIG.autoBuy != 'none') {
+ console.log('Trying to buy more charges before swapping account...');
+ const purchaseResult = await purchase(CONFIG.autoBuy);
+ if (purchaseResult == 2) {
+ console.log('ā
Purchase successful, continuing painting');
+ await updateStats();
+ await updateCurrentAccountInList();
+ if (CONFIG.autoBuy == 'paint_charges') continue;
+ }
+ else if (purchaseResult == 1) {
+ console.log('š Not enough droplets to buy more charges, swapping account.');
+ }
+ else {
+ console.log('š¤ Purchase failed.');
+ }
+ }
+ else console.log('𤫠Auto buy is disabled, wait for cooldown or swapping account.');
+ if (!CONFIG.autoSwap) {
+ // Original workflow: cooldown period
+ console.log('ā±ļø Phase 2: Entering cooldown period (auto-swap disabled)');
+ const cooldownResult = await executeCooldownPeriod();
+
+ if (cooldownResult === 'stopped') {
+ console.log('ā¹ļø Cooldown stopped by user');
+ break;
+ }
+
+ // Phase 3: Regenerate token for next painting session
+ console.log('š Phase 3: Regenerating token for next session');
+ const tokenResult = await regenerateTokenForNewSession();
+
+ if (!tokenResult) {
+ console.log('ā Failed to regenerate token, stopping');
+ state.stopFlag = true;
+ break;
+ }
+ } else {
+ // Auto-swap workflow: simplified logic
+ console.log('š Auto-swap enabled: checking account switching options');
+
+ // Retrieve fresh accounts list each time to avoid stale data
+ const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
+ console.log(`š Retrieved ${accounts.length} accounts from localStorage`);
+
+ if (accounts.length <= 1) {
+ console.log('š Only one account available, using standard cooldown');
+ const cooldownResult = await executeCooldownPeriod();
+ if (cooldownResult === 'stopped') break;
+
+ const tokenResult = await regenerateTokenForNewSession();
+ if (!tokenResult) {
+ state.stopFlag = true;
+ break;
+ }
+ } else {
+ // Debug current state
+ const totalAccounts = accountManager.getAccountCount();
+ console.log(`š Account Status - Current index: ${accountManager.currentIndex}, Total accounts: ${totalAccounts}`);
+
+ // Check if we're at the last account in the sequence
+ const isLastAccount = accountManager.currentIndex >= totalAccounts - 1;
+ console.log(`š Is last account? ${isLastAccount} (index ${accountManager.currentIndex} of ${totalAccounts})`);
+
+ if (!isLastAccount && totalAccounts > 1) {
+ // Update current account status before switching
+ console.log('š Updating current account status before switch...');
+ await updateCurrentAccountInList();
+
+ // Switch to next account immediately (no cooldown) - only if we have multiple accounts
+ const nextAccount = accountManager.getNextAccount();
+ console.log(`š Switching to next account: ${nextAccount?.displayName} (${accountManager.currentIndex + 2}/${totalAccounts})`);
+ const switchResult = await switchToNextAccount(accounts);
+ if (!switchResult) {
+ console.log('ā Account switch failed, stopping');
+ state.stopFlag = true;
+ break;
+ }
+ // Continue painting with new account immediately
+ continue;
+ } else if (totalAccounts === 1) {
+ // Only one account available - use cooldown and continue with same account
+ console.log('ā¹ļø Only one account available, entering cooldown period');
+ const cooldownResult = await executeCooldownPeriod();
+ if (cooldownResult === 'stopped') break;
+ console.log('ā
Cooldown complete, continuing with same account');
+ continue;
+ } else {
+ // Last account reached - use cooldown then switch to first account
+ console.log('ā±ļø Last account reached, entering cooldown period');
+ console.log(`š Current account: index ${accountManager.currentIndex}, Last account: index ${totalAccounts - 1}`);
+
+ const cooldownResult = await executeCooldownPeriod();
+ if (cooldownResult === 'stopped') break;
+
+ // After cooldown, switch to first account
+ console.log('š Cooldown complete, switching to first account');
+ console.log(`š Before switch - Current index: ${accountManager.currentIndex}, Target: index 0`);
+
+ const firstAccountInfo = accountManager.getAccountByIndex(0);
+ const firstAccountToken = firstAccountInfo?.token;
+
+ if (!firstAccountToken) {
+ console.log('ā First account token not found, stopping');
+ state.stopFlag = true;
+ break;
+ }
+
+ // Reset to first position
+ accountManager.setCurrentIndex(0);
+
+ const switchResult = await switchToSpecificAccount(firstAccountToken, firstAccountInfo.displayName);
+ if (!switchResult) {
+ console.log('ā Switch to ID 1 failed, stopping');
+ state.stopFlag = true;
+ break;
+ }
+
+ console.log(`ā
Successfully switched to ID 1 (${firstAccountInfo.displayName}). Cycle restarted.`);
+ // Continue painting with first account
+ continue;
+ }
+ }
+ }
+ }
+
+ console.log('š Cycle complete, starting next painting session');
+ }
+ } finally {
+ await finalizePaintingProcess();
+ }
+ }
+
+ // Phase 1: Execute a complete painting session using all available charges
+ async function executePaintingSession() {
+ console.log('šØ Starting painting session - using all charges until 0');
+ const { width, height, pixels } = state.imageData;
+ const { x: startX, y: startY } = state.startPosition;
+ const { x: regionX, y: regionY } = state.region;
+
+ // Check if we're working with restored data by looking for existing availableColors
+ const isRestoredData = state.availableColors && state.availableColors.length > 0 && state.colorsChecked;
+
+ if (!isRestoredData) {
+ // Wait for original tiles to load if needed
+ const tilesReady = await overlayManager.waitForTiles(
+ regionX,
+ regionY,
+ width,
+ height,
+ startX,
+ startY,
+ 10000 // timeout 10s
+ );
+
+ if (!tilesReady) {
+ updateUI('overlayTilesNotLoaded', 'error');
+ state.stopFlag = true;
+ return 'stopped';
+ }
+ }
+
+ let pixelBatch = null;
+ let skippedPixels = {
+ transparent: 0,
+ white: 0,
+ alreadyPainted: 0,
+ colorUnavailable: 0,
+ };
+
+ // IMPORTANT: Check charges once at start, then paint until depleted
+ console.log('š Checking initial charges for painting session');
+ const initialChargeCheck = await WPlaceService.getCharges();
+ state.displayCharges = Math.floor(initialChargeCheck.charges);
+ state.preciseCurrentCharges = initialChargeCheck.charges;
+ state.cooldown = initialChargeCheck.cooldown;
+ await updateStats();
+ if (state.displayCharges <= 0) {
+ console.log('ā” No charges available, skipping painting session');
+ return 'charges_depleted';
+ }
+
+ console.log(`š Starting with ${state.displayCharges} charges - painting until depleted`);
+
+ // Paint pixels until we run out of charges or complete the image
+ try {
+
+ // Generate the actual filtered coordinates for painting (resuming from last position)
+ const coords = await generateCoordinatesAsync(
+ width,
+ height,
+ state.coordinateMode,
+ state.coordinateDirection,
+ state.coordinateSnake,
+ state.blockWidth,
+ state.blockHeight,
+ state.lastPosition.x,
+ state.lastPosition.y
+ );
+
+ // OPTIMIZATION: Pre-filter already painted pixels (happens only once per session)
+ let eligibleCoords = [];
+ let alreadyPaintedCount = 0;
+
+ if (!state.preFilteringDone) {
+ console.log('š Pre-filtering already painted pixels (one-time detection for this session)...');
+
+ for (const [x, y] of coords) {
+ const targetPixelInfo = checkPixelEligibility(x, y);
+
+ if (!targetPixelInfo.eligible) {
+ if (targetPixelInfo.reason !== 'alreadyPainted') {
+ skippedPixels[targetPixelInfo.reason]++;
+
+ }
+ continue;
+ }
+
+ // Check if already painted (only once per session)
+ let absX = startX + x;
+ let absY = startY + y;
+ let adderX = Math.floor(absX / 1000);
+ let adderY = Math.floor(absY / 1000);
+ let pixelX = absX % 1000;
+ let pixelY = absY % 1000;
+
+ try {
+ const tilePixelRGBA = await overlayManager.getTilePixelColor(
+ regionX + adderX,
+ regionY + adderY,
+ pixelX,
+ pixelY
+ );
+
+ if (tilePixelRGBA && Array.isArray(tilePixelRGBA)) {
+ const mappedCanvasColor = Utils.resolveColor(
+ tilePixelRGBA.slice(0, 3),
+ state.availableColors,
+ !state.paintUnavailablePixels // Use same parameter as target pixel
+ );
+ const isMatch = mappedCanvasColor.id === targetPixelInfo.mappedColorId;
+ if (isMatch) {
+ alreadyPaintedCount++;
+ // Mark as painted in map but DO NOT increment progress counter
+ // Progress should only reflect actual painting sequence position
+ Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
+ continue; // Skip already painted pixels
+ }
+ }
+ } catch (e) {
+ // If we can't check, include the pixel (better to attempt than skip)
+ }
+
+ // Add eligible unpainted pixel to list
+ eligibleCoords.push([x, y, targetPixelInfo]);
+ }
+
+ // Mark pre-filtering as done for this session
+ state.preFilteringDone = true;
+
+ // Log pre-filtering results
+ if (alreadyPaintedCount > 0) {
+ console.log(`ā Pre-filter complete: ${alreadyPaintedCount} already painted pixels detected (not added to progress counter)`);
+ console.log('ā¹ļø This detection will not happen again until a new image/save is loaded');
+ console.log('š Progress counter only reflects actual painting sequence position');
+ // No need to update stats since progress wasn't changed
+ }
+ skippedPixels.alreadyPainted = alreadyPaintedCount;
+ } else {
+ // Pre-filtering already done this session, just filter for basic eligibility
+ console.log('š Using existing pre-filter results (already done this session)');
+ for (const [x, y] of coords) {
+ const targetPixelInfo = checkPixelEligibility(x, y);
+
+ if (!targetPixelInfo.eligible) {
+ if (targetPixelInfo.reason !== 'alreadyPainted') {
+ skippedPixels[targetPixelInfo.reason]++;
+ }
+ continue;
+ }
+
+ // Only include pixels that haven't been marked as painted yet
+ let absX = startX + x;
+ let absY = startY + y;
+ let adderX = Math.floor(absX / 1000);
+ let adderY = Math.floor(absY / 1000);
+
+ if (!Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
+ eligibleCoords.push([x, y, targetPixelInfo]);
+ }
+ }
+ }
+
+ // Group pixels by color if color-by-color mode is enabled
+ let pixelsToProcess = eligibleCoords;
+ if (state.paintingOrder === 'color-by-color') {
+ console.log('šØ Color-by-color mode enabled - grouping pixels by color');
+
+ // Group pixels by color ID
+ const colorGroups = new Map();
+ for (const [x, y, targetPixelInfo] of eligibleCoords) {
+ const colorId = targetPixelInfo.mappedColorId;
+ if (!colorGroups.has(colorId)) {
+ colorGroups.set(colorId, []);
+ }
+ colorGroups.get(colorId).push([x, y, targetPixelInfo]);
+ }
+
+ // Log color groups
+ console.log(`š Found ${colorGroups.size} different colors to paint:`);
+ for (const [colorId, pixels] of colorGroups.entries()) {
+ const colorInfo = Object.values(CONFIG.COLOR_MAP).find(c => c.id === colorId);
+ const colorName = colorInfo ? colorInfo.name : `Color ${colorId}`;
+ console.log(` šØ ${colorName} (ID: ${colorId}): ${pixels.length} pixels`);
+ }
+
+ // Process colors one by one
+ const sortedColorGroups = Array.from(colorGroups.entries()).sort((a, b) => a[0] - b[0]);
+
+ // If we have a current painting color, resume from that color
+ let startIndex = 0;
+ if (state.currentPaintingColor !== null) {
+ startIndex = sortedColorGroups.findIndex(([colorId]) => colorId === state.currentPaintingColor);
+ if (startIndex === -1) startIndex = 0;
+ console.log(`š Resuming from color ID ${state.currentPaintingColor} (index ${startIndex})`);
+ }
+
+ // Flatten the groups starting from the current color
+ pixelsToProcess = [];
+ for (let i = startIndex; i < sortedColorGroups.length; i++) {
+ const [colorId, pixels] = sortedColorGroups[i];
+ // Avoid using spread or concat with very large arrays to prevent call stack/alloc overhead
+ for (let j = 0; j < pixels.length; j++) {
+ pixelsToProcess.push(pixels[j]);
+ }
+ }
+
+ console.log(`ā
Prepared ${pixelsToProcess.length} pixels for color-by-color painting`);
+ }
+
+ // Paint eligible pixels (already pre-filtered, no duplicate checks)
+ outerLoop: for (const [x, y, targetPixelInfo] of pixelsToProcess) {
+ // Track current color being painted in color-by-color mode
+ if (state.paintingOrder === 'color-by-color') {
+ if (state.currentPaintingColor !== targetPixelInfo.mappedColorId) {
+ state.currentPaintingColor = targetPixelInfo.mappedColorId;
+ const colorInfo = Object.values(CONFIG.COLOR_MAP).find(c => c.id === state.currentPaintingColor);
+ const colorName = colorInfo ? colorInfo.name : `Color ${state.currentPaintingColor}`;
+ console.log(`šØ Now painting: ${colorName} (ID: ${state.currentPaintingColor})`);
+
+ // Update UI to show current color
+ const statusDiv = document.getElementById('statusDiv');
+ if (statusDiv) {
+ const colorMessage = Utils.t('currentlyPaintingColor', { colorName });
+ const colorIndicator = document.getElementById('currentColorIndicator');
+ if (colorIndicator) {
+ colorIndicator.textContent = colorMessage;
+ } else {
+ const indicator = document.createElement('div');
+ indicator.id = 'currentColorIndicator';
+ indicator.textContent = colorMessage;
+ indicator.style.cssText = 'margin-top: 8px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 6px; font-weight: bold;';
+ statusDiv.appendChild(indicator);
+ }
+ }
+ }
+ }
+
+ if (state.stopFlag) {
+ if (pixelBatch && pixelBatch.pixels.length > 0) {
+ console.log(`šÆ Sending last batch before stop with ${pixelBatch.pixels.length} pixels`);
+ await flushPixelBatch(pixelBatch);
+ }
+ state.lastPosition = { x, y };
+ // Show paused coordinates in UI with proper translation template
+ // Use last painted position if available, otherwise use current position
+ const pausedX = state.lastPaintedPosition.x || x;
+ const pausedY = state.lastPaintedPosition.y || y;
+ updateUI('paintingPaused', 'warning', { x: pausedX, y: pausedY });
+ console.log(`āøļø Painting paused after last painted coordinates (${pausedX}, ${pausedY})`);
+ return 'stopped';
+ }
+
+ // Check if we have charges left (local count, no API call)
+ if (state.displayCharges <= 0) {
+ // console.log("Try to buy paint charges");
+ // await purchase("paint_charges");
+ await updateStats();
+ if (state.displayCharges <= 0) {
+ console.log('ā” No charges left (local count), ending painting session');
+ if (pixelBatch && pixelBatch.pixels.length > 0) {
+ console.log(`šÆ Sending final batch with ${pixelBatch.pixels.length} pixels`);
+ await flushPixelBatch(pixelBatch);
+ }
+ state.lastPosition = { x, y };
+ return 'charges_depleted';
+ }
+ // else {
+ // console.log(`š Charges after purchase: ${state.displayCharges}, continuing painting`);
+ // }
+ }
+
+ let absX = startX + x;
+ let absY = startY + y;
+
+ let adderX = Math.floor(absX / 1000);
+ let adderY = Math.floor(absY / 1000);
+ let pixelX = absX % 1000;
+ let pixelY = absY % 1000;
+
+ // CRITICAL FIX: Always check if pixel is already painted (both locally and on canvas)
+ if (Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
+ console.log(`āļø Skipping already painted pixel at (${x}, ${y}) - marked in local map`);
+ continue; // Skip already painted pixels
+ }
+
+ // REAL-TIME CANVAS CHECK: Verify against actual canvas state to prevent overpainting
+ try {
+ const existingColorRGBA = await overlayManager.getTilePixelColor(
+ regionX + adderX,
+ regionY + adderY,
+ pixelX,
+ pixelY
+ ).catch(() => null);
+
+ if (existingColorRGBA && Array.isArray(existingColorRGBA)) {
+ const [er, eg, eb] = existingColorRGBA;
+ const existingMappedColor = Utils.resolveColor(
+ [er, eg, eb],
+ state.availableColors,
+ !state.paintUnavailablePixels
+ );
+ const isAlreadyCorrect = existingMappedColor.id === targetPixelInfo.mappedColorId;
+
+ if (isAlreadyCorrect) {
+ console.log(`ā
Pixel at (${x}, ${y}) already has correct color (${existingMappedColor.id}) - marking as painted`);
+ // Mark it as painted in local map but DO NOT increment progress counter
+ // Progress should only reflect actual painting sequence position
+ Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
+ continue; // Skip painting this pixel
+ }
+ }
+ } catch (e) {
+ // If we can't check the canvas, proceed with painting (better to attempt than skip)
+ console.warn(`ā ļø Could not verify canvas state for pixel (${x}, ${y}), proceeding with paint:`, e.message);
+ }
+
+ const targetMappedColorId = targetPixelInfo.mappedColorId;
+
+ // Set up pixel batch for new region if needed
+ if (
+ !pixelBatch ||
+ pixelBatch.regionX !== regionX + adderX ||
+ pixelBatch.regionY !== regionY + adderY
+ ) {
+ if (pixelBatch && pixelBatch.pixels.length > 0) {
+ console.log(`š Sending region-change batch with ${pixelBatch.pixels.length} pixels`);
+ const success = await flushPixelBatch(pixelBatch);
+ if (!success) {
+ console.error(`ā Batch failed permanently after retries. Stopping painting.`);
+ state.stopFlag = true;
+ return 'stopped';
+ }
+ await updateStats();
+ }
+
+ pixelBatch = {
+ regionX: regionX + adderX,
+ regionY: regionY + adderY,
+ pixels: [],
+ };
+ }
+
+ // Add pixel to batch (no need to check again - already pre-filtered)
+ pixelBatch.pixels.push({
+ x: pixelX,
+ y: pixelY,
+ color: targetMappedColorId,
+ localX: x,
+ localY: y,
+ });
+
+ // Send batch if it's full
+ const maxBatchSize = calculateBatchSize();
+ if (pixelBatch.pixels.length >= maxBatchSize) {
+ console.log(`š¦ Sending batch with ${pixelBatch.pixels.length} pixels`);
+ const success = await flushPixelBatch(pixelBatch);
+ if (!success) {
+ console.error(`ā Batch failed permanently after retries. Stopping painting.`);
+ state.stopFlag = true;
+ return 'stopped';
+ }
+ pixelBatch.pixels = [];
+ await updateStats();
+ }
+ }
+
+ // Send final batch if any pixels remain
+ if (pixelBatch && pixelBatch.pixels.length > 0 && !state.stopFlag) {
+ console.log(`š Sending final batch with ${pixelBatch.pixels.length} pixels`);
+ const success = await flushPixelBatch(pixelBatch);
+ if (!success) {
+ console.warn(`ā ļø Final batch failed with ${pixelBatch.pixels.length} pixels`);
+ }
+ }
+
+ // If we completed the entire coordinate loop, image is complete
+ return state.stopFlag ? 'stopped' : 'completed';
+
+ } finally {
+ // Log skip statistics for this session
+ console.log(`š Session Statistics:`);
+ console.log(` New pixels painted: ${state.paintedPixels - (skippedPixels.alreadyPainted || 0)}`);
+ console.log(` Already painted detected: ${skippedPixels.alreadyPainted}`);
+ console.log(` Total progress: ${state.paintedPixels}`);
+ console.log(` Pre-filtered - Transparent: ${skippedPixels.transparent}`);
+ console.log(` Pre-filtered - White: ${skippedPixels.white}`);
+ console.log(` Pre-filtered - Color Unavailable: ${skippedPixels.colorUnavailable}`);
+ }
+ }
+
+ // Phase 2: Execute cooldown period - wait for target charges (NO token regeneration)
+ async function executeCooldownPeriod() {
+ console.log('ā±ļø Entering cooldown period - waiting for target charges');
+ console.log('š« NO token regeneration during cooldown (even if expired/invalid)');
+
+ // Check initial charges to calculate wait time
+ let chargeCheckCount = 0;
+ // const maxChargeChecks = 10; // REMOVED: No limit on API calls during cooldown
+
+ while (!state.stopFlag) {
+ chargeCheckCount++;
+
+ const { charges, cooldown } = await WPlaceService.getCharges();
+ state.displayCharges = Math.floor(charges);
+ state.preciseCurrentCharges = charges;
+ state.cooldown = cooldown;
+
+ if (state.displayCharges >= state.cooldownChargeThreshold) {
+ console.log(`ā
Cooldown target reached: ${state.displayCharges}/${state.cooldownChargeThreshold}`);
+ NotificationManager.maybeNotifyChargesReached(true);
+ await updateStats();
+ return 'target_reached';
+ }
+
+ updateUI('noChargesThreshold', 'warning', {
+ time: Utils.msToTimeText(state.cooldown),
+ threshold: state.cooldownChargeThreshold,
+ current: state.displayCharges,
+ });
+ await updateStats();
+
+ // Smart delay calculation to reduce API calls
+ const chargesNeeded = state.cooldownChargeThreshold - state.displayCharges;
+ const estimatedWaitTime = chargesNeeded * state.cooldown;
+
+ // Use longer delays during cooldown to prevent rate limiting
+ let delayTime;
+ if (chargeCheckCount < 3) {
+ // First few checks - shorter delay
+ delayTime = Math.max(3000, state.cooldown); // 3 seconds minimum
+ } else if (estimatedWaitTime > 60000) {
+ // Long wait expected - check every 15 seconds
+ delayTime = 10000;
+ } else if (estimatedWaitTime > 30000) {
+ // Medium wait - check every 15 seconds
+ delayTime = 15000;
+ } else {
+ // Close to target - check every 5 seconds
+ delayTime = 5000;
+ }
+
+ console.log(`ā±ļø Cooldown check ${chargeCheckCount}: ${state.displayCharges}/${state.cooldownChargeThreshold} charges, waiting 10s before next check`);
+ await Utils.sleep(10000);
+
+ // REMOVED: No limit on charge checks - bot will wait infinitely until charges are available
+ // if (chargeCheckCount >= maxChargeChecks) {
+ // console.warn('ā ļø Max charge checks reached during cooldown, continuing anyway');
+ // break;
+ // }
+ }
+
+ return 'stopped';
+ }
+
+ // Phase 3: Regenerate token for new painting session
+ async function regenerateTokenForNewSession() {
+ console.log('š Regenerating token for new painting session');
+
+ try {
+ // Force regenerate token for new session
+ await ensureToken(true); // forceRefresh = true
+
+ if (!getTurnstileToken()) {
+ console.error('ā Failed to generate token for new session');
+ return false;
+ }
+
+ console.log('ā
Token regenerated successfully for new session');
+ return true;
+ } catch (error) {
+ console.error('ā Token regeneration failed:', error);
+ return false;
+ }
+ }
+
+ // Finalize painting process cleanup
+ async function finalizePaintingProcess() {
+ console.log('š§¹ Finalizing painting process');
+
+ if (window._chargesInterval) {
+ clearInterval(window._chargesInterval);
+ window._chargesInterval = null;
+ }
+
+ if (state.stopFlag) {
+ // Save progress when stopped to preserve painted map
+ Utils.saveProgress();
+ } else {
+ updateUI('paintingComplete', 'success', { count: state.paintedPixels });
+ state.lastPosition = { x: 0, y: 0 }; // Only reset when truly complete
+ Utils.saveProgress(); // Save final complete state
+ overlayManager.clear();
+ const toggleOverlayBtn = document.getElementById('toggleOverlayBtn');
+ if (toggleOverlayBtn) {
+ toggleOverlayBtn.classList.remove('active');
+ toggleOverlayBtn.disabled = true;
+ }
+ }
+ }
+
+ // Helper function to check pixel eligibility (shared by painting functions)
+ function checkPixelEligibility(x, y) {
+ const { width, height, pixels } = state.imageData;
+ const transparencyThreshold = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
+
+ // CRITICAL FIX: Check module availability before processing
+ if (!Utils || typeof Utils.isWhitePixel !== 'function') {
+ console.error('ā Utils module not available for pixel eligibility check');
+ return {
+ eligible: false,
+ reason: 'moduleUnavailable',
+ };
+ }
+
+ const idx = (y * width + x) * 4;
+ const r = pixels[idx],
+ g = pixels[idx + 1],
+ b = pixels[idx + 2],
+ a = pixels[idx + 3];
+
+ if (!state.paintTransparentPixels && a < transparencyThreshold)
+ return {
+ eligible: false,
+ reason: 'transparent',
+ };
+ if (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
+ return {
+ eligible: false,
+ reason: 'white',
+ };
+
+ let targetRgb = Utils.isWhitePixel(r, g, b)
+ ? [255, 255, 255]
+ : Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
+
+ const mappedTargetColorId = Utils.resolveColor(
+ targetRgb,
+ state.availableColors,
+ !state.paintUnavailablePixels
+ );
+
+ if (!state.paintUnavailablePixels && !mappedTargetColorId.id) {
+ return {
+ eligible: false,
+ reason: 'colorUnavailable',
+ r,
+ g,
+ b,
+ a,
+ mappedColorId: mappedTargetColorId.id,
+ };
+ }
+ return { eligible: true, r, g, b, a, mappedColorId: mappedTargetColorId.id };
+ }
+
+ // Helper function to skip pixel and log the reason (minimized logging)
+ function skipPixel(reason, id, rgb, x, y, skippedPixels) {
+ // Minimize logging to prevent console flooding - only log non-routine skips
+ if (reason !== 'transparent' && reason !== 'alreadyPainted') {
+ console.log(`Skipped pixel for ${reason} (id: ${id}, (${rgb.join(', ')})) at (${x}, ${y})`);
+ }
+ skippedPixels[reason]++;
+ }
+ function calculateBatchSize() {
+ let targetBatchSize;
+
+ // If speed control is disabled, use all available charges
+ if (!CONFIG.PAINTING_SPEED_ENABLED) {
+ targetBatchSize = state.displayCharges;
+ console.log(`š Speed control disabled: using all ${targetBatchSize} available charges`);
+ return Math.max(1, targetBatchSize);
+ }
+
+ // CRITICAL FIX: When speed control is ENABLED, use actual available charges as the batch size
+ // This ensures bot sends exactly the number of pixels matching available charges
+ // Example: 75 charges ā send 75 pixels, 14 charges ā send 14 pixels
+ const availableCharges = state.displayCharges;
+
+ if (state.batchMode === 'random') {
+ // Generate random batch size within the specified range
+ const min = Math.max(1, state.randomBatchMin);
+ const max = Math.max(min, state.randomBatchMax);
+ targetBatchSize = Math.floor(Math.random() * (max - min + 1)) + min;
+ console.log(`š² Random batch size generated: ${targetBatchSize} (range: ${min}-${max})`);
+ } else {
+ // Normal mode - use the fixed paintingSpeed value
+ targetBatchSize = state.paintingSpeed;
+ }
+
+ // FIXED: Use actual available charges as the maximum batch size
+ // This matches the available pixels exactly (e.g., 75 charges = 75 pixel batch)
+ const finalBatchSize = Math.min(targetBatchSize, availableCharges);
+
+ console.log(`š Batch size: ${finalBatchSize} (target: ${targetBatchSize}, available: ${availableCharges})`);
+
+ return Math.max(1, finalBatchSize);
+ }
+
+ // Helper function to retry batch until success with exponential backoff
+ async function sendBatchWithRetry(pixels, regionX, regionY, maxRetries = MAX_BATCH_RETRIES) {
+ let attempt = 0;
+ while (attempt < maxRetries && !state.stopFlag) {
+ attempt++;
+ console.log(
+ `š Attempting to send batch (attempt ${attempt}/${maxRetries}) for region ${regionX},${regionY} with ${pixels.length} pixels`
+ );
+
+ const result = await sendPixelBatch(pixels, regionX, regionY);
+
+ if (result === true) {
+ console.log(`ā
Batch succeeded on attempt ${attempt}`);
+ return true;
+ } else if (result === 'token_error') {
+ console.log(`š Token error on attempt ${attempt} - no token available during processing`);
+ console.log(`ā Stopping batch processing - tokens must be generated at startup/start button only`);
+ updateUI('captchaFailed', 'error');
+ await Utils.sleep(2000); // Wait longer before retrying after token failure
+ continue; // Continue to retry until maxRetries reached
+ } else if (result === 'token_regenerated') {
+ console.log(`š Token regenerated on attempt ${attempt} after 403 error - retrying batch`);
+ const pausedX = state.lastPaintedPosition.x;
+ const pausedY = state.lastPaintedPosition.y;
+ updateUI('paintingPaused', 'warning', { x: pausedX, y: pausedY });
+ // Don't count token regeneration as a failed attempt, retry immediately
+ attempt--;
+ await Utils.sleep(500); // Brief pause before retry
+ continue;
+ } else if (result === 'token_regeneration_failed') {
+ console.log(`ā Token regeneration failed on attempt ${attempt} after 403 error`);
+ updateUI('captchaFailed', 'error');
+ return false; // Stop processing if we can't get a valid token
+ } else if (result === 'invalid_token_error') {
+ console.log(`š Invalid token detected on attempt ${attempt}, regenerating...`);
+ updateUI('captchaSolving', 'warning');
+ try {
+ await handleCaptcha(true); // Allow generation for invalid token cases
+ // Don't count token regeneration as a failed attempt
+ attempt--;
+ continue;
+ } catch (e) {
+ console.error(`ā Token regeneration failed after invalid token on attempt ${attempt}:`, e);
+ updateUI('captchaFailed', 'error');
+ // Wait longer before retrying after token failure
+ await Utils.sleep(5000);
+ }
+ } else {
+ console.warn(`ā ļø Batch failed on attempt ${attempt}, retrying...`);
+ // Exponential backoff with jitter
+ const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 30000); // Max 30s
+ const jitter = Math.random() * 1000; // Add up to 1s random delay
+ await Utils.sleep(baseDelay + jitter);
+ }
+ }
+
+ if (attempt >= maxRetries) {
+ console.error(
+ `ā Batch failed after ${maxRetries} attempts (MAX_BATCH_RETRIES=${MAX_BATCH_RETRIES}). This will stop painting to prevent infinite loops.`
+ );
+ updateUI('paintingError', 'error');
+ return false;
+ }
+
+ return false;
+ }
+
+ async function sendPixelBatch(pixelBatch, regionX, regionY) {
+ let token = getTurnstileToken();
+
+ // Don't auto-generate tokens during processing - return error if no token available
+ if (!token) {
+ console.warn('ā ļø No token available and auto-generation disabled during processing');
+ return 'token_error';
+ }
+
+ const coords = new Array(pixelBatch.length * 2);
+ const colors = new Array(pixelBatch.length);
+ for (let i = 0; i < pixelBatch.length; i++) {
+ const pixel = pixelBatch[i];
+ coords[i * 2] = pixel.x;
+ coords[i * 2 + 1] = pixel.y;
+ colors[i] = pixel.color;
+ }
+
+ try {
+ const payload = { coords, colors, t: token, fp: fpStr32 };
+ var wasmtoken = await createWasmToken(regionX, regionY, payload);
+ const res = await fetch(`https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/plain;charset=UTF-8', "x-pawtect-token": wasmtoken },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ });
+
+ if (res.status === 403) {
+ let data = null;
+ try {
+ data = await res.json();
+ } catch (_) { }
+ console.error('ā 403 Forbidden. Token invalid during painting - regeneration allowed.');
+
+ // 403 errors during painting allow token regeneration per workflow requirements
+ console.log('ļæ½ Token invalid (403) during painting - regenerating token as allowed by workflow');
+ setTurnstileToken(null);
+ createTokenPromise();
+
+ // Attempt to regenerate token immediately
+ const newToken = await ensureToken(true);
+ if (newToken) {
+ console.log('ā
Token regenerated after 403 error, returning regenerate signal');
+ return 'token_regenerated';
+ } else {
+ console.error('ā Failed to regenerate token after 403 error');
+ return 'token_regeneration_failed';
+ }
+ }
+
+ const data = await res.json();
+ return data?.painted === pixelBatch.length;
+ } catch (e) {
+ console.error('Batch paint request failed:', e);
+ return false;
+ }
+ }
+
+ function saveBotSettings() {
+ try {
+ const settings = {
+ paintingSpeed: state.paintingSpeed,
+ paintingSpeedEnabled: document.getElementById('enableSpeedToggle')?.checked,
+ batchMode: state.batchMode, // "normal" or "random"
+ paintingOrder: state.paintingOrder, // "sequential" or "color-by-color"
+ randomBatchMin: state.randomBatchMin,
+ randomBatchMax: state.randomBatchMax,
+ cooldownChargeThreshold: state.cooldownChargeThreshold,
+ tokenSource: state.tokenSource, // "generator", "hybrid", or "manual"
+ minimized: state.minimized,
+ overlayOpacity: state.overlayOpacity,
+ blueMarbleEnabled: document.getElementById('enableBlueMarbleToggle')?.checked,
+ ditheringEnabled: state.ditheringEnabled,
+ colorMatchingAlgorithm: state.colorMatchingAlgorithm,
+ enableChromaPenalty: state.enableChromaPenalty,
+ chromaPenaltyWeight: state.chromaPenaltyWeight,
+ customTransparencyThreshold: state.customTransparencyThreshold,
+ customWhiteThreshold: state.customWhiteThreshold,
+ paintWhitePixels: state.paintWhitePixels,
+ paintTransparentPixels: state.paintTransparentPixels,
+ resizeSettings: state.resizeSettings,
+ paintUnavailablePixels: state.paintUnavailablePixels,
+ coordinateMode: state.coordinateMode,
+ coordinateDirection: state.coordinateDirection,
+ coordinateSnake: state.coordinateSnake,
+ blockWidth: state.blockWidth,
+ blockHeight: state.blockHeight, // Save ignore mask (as base64) with its dimensions
+ resizeIgnoreMask:
+ state.resizeIgnoreMask &&
+ state.resizeSettings &&
+ state.resizeSettings.width * state.resizeSettings.height === state.resizeIgnoreMask.length
+ ? {
+ w: state.resizeSettings.width,
+ h: state.resizeSettings.height,
+ data: btoa(String.fromCharCode(...state.resizeIgnoreMask)),
+ }
+ : null, // Notifications
+ notificationsEnabled: state.notificationsEnabled,
+ notifyOnChargesReached: state.notifyOnChargesReached,
+ notifyOnlyWhenUnfocused: state.notifyOnlyWhenUnfocused,
+ notificationIntervalMinutes: state.notificationIntervalMinutes,
+ originalImage: state.originalImage,
+ };
+ CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled;
+ // AUTO_CAPTCHA_ENABLED is always true - no need to save/load
+
+ localStorage.setItem('wplace-bot-settings', JSON.stringify(settings));
+ } catch (e) {
+ console.warn('Could not save bot settings:', e);
+ }
+ }
+
+ function loadBotSettings() {
+ try {
+ const saved = localStorage.getItem('wplace-bot-settings');
+ if (!saved) return;
+ const settings = JSON.parse(saved);
+
+ state.paintingSpeed = settings.paintingSpeed || CONFIG.PAINTING_SPEED.DEFAULT;
+ state.batchMode = settings.batchMode || CONFIG.BATCH_MODE; // Default to "normal"
+ state.paintingOrder = settings.paintingOrder || CONFIG.PAINTING_ORDER; // Default to "sequential"
+ state.randomBatchMin = settings.randomBatchMin || CONFIG.RANDOM_BATCH_RANGE.MIN;
+ state.randomBatchMax = settings.randomBatchMax || CONFIG.RANDOM_BATCH_RANGE.MAX;
+ state.cooldownChargeThreshold =
+ settings.cooldownChargeThreshold || CONFIG.COOLDOWN_CHARGE_THRESHOLD;
+ state.tokenSource = settings.tokenSource || CONFIG.TOKEN_SOURCE; // Default to "generator"
+ state.minimized = settings.minimized ?? false;
+ CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled ?? false;
+ CONFIG.AUTO_CAPTCHA_ENABLED = settings.autoCaptchaEnabled ?? false;
+ state.overlayOpacity = settings.overlayOpacity ?? CONFIG.OVERLAY.OPACITY_DEFAULT;
+ state.blueMarbleEnabled = settings.blueMarbleEnabled ?? CONFIG.OVERLAY.BLUE_MARBLE_DEFAULT;
+ state.ditheringEnabled = settings.ditheringEnabled ?? false;
+ state.colorMatchingAlgorithm = settings.colorMatchingAlgorithm || 'lab';
+ state.enableChromaPenalty = settings.enableChromaPenalty ?? true;
+ state.chromaPenaltyWeight = settings.chromaPenaltyWeight ?? 0.15;
+ state.customTransparencyThreshold =
+ settings.customTransparencyThreshold ?? CONFIG.TRANSPARENCY_THRESHOLD;
+ state.customWhiteThreshold = settings.customWhiteThreshold ?? CONFIG.WHITE_THRESHOLD;
+ state.paintWhitePixels = settings.paintWhitePixels ?? true;
+ state.paintTransparentPixels = settings.paintTransparentPixels ?? false;
+ state.resizeSettings = settings.resizeSettings ?? null;
+ state.originalImage = settings.originalImage ?? null;
+ state.paintUnavailablePixels = settings.paintUnavailablePixels ?? CONFIG.PAINT_UNAVAILABLE;
+ state.coordinateMode = settings.coordinateMode ?? CONFIG.COORDINATE_MODE;
+ state.coordinateDirection = settings.coordinateDirection ?? CONFIG.COORDINATE_DIRECTION;
+ state.coordinateSnake = settings.coordinateSnake ?? CONFIG.COORDINATE_SNAKE;
+ state.blockWidth = settings.blockWidth ?? CONFIG.COORDINATE_BLOCK_WIDTH;
+ state.blockHeight = settings.blockHeight ?? CONFIG.COORDINATE_BLOCK_HEIGHT;
+ // Notifications
+ state.notificationsEnabled = settings.notificationsEnabled ?? CONFIG.NOTIFICATIONS.ENABLED;
+ state.notifyOnChargesReached =
+ settings.notifyOnChargesReached ?? CONFIG.NOTIFICATIONS.ON_CHARGES_REACHED;
+ state.notifyOnlyWhenUnfocused =
+ settings.notifyOnlyWhenUnfocused ?? CONFIG.NOTIFICATIONS.ONLY_WHEN_UNFOCUSED;
+ state.notificationIntervalMinutes =
+ settings.notificationIntervalMinutes ?? CONFIG.NOTIFICATIONS.REPEAT_MINUTES;
+ // Restore ignore mask if dims match current resizeSettings
+ if (
+ settings.resizeIgnoreMask &&
+ settings.resizeIgnoreMask.data &&
+ state.resizeSettings &&
+ settings.resizeIgnoreMask.w === state.resizeSettings.width &&
+ settings.resizeIgnoreMask.h === state.resizeSettings.height
+ ) {
+ try {
+ const bin = atob(settings.resizeIgnoreMask.data);
+ const arr = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
+ state.resizeIgnoreMask = arr;
+ } catch {
+ state.resizeIgnoreMask = null;
+ }
+ } else {
+ state.resizeIgnoreMask = null;
+ }
+ // Initialize coordinate generation UI
+ const coordinateModeSelect = document.getElementById('coordinateModeSelect');
+ if (coordinateModeSelect) coordinateModeSelect.value = state.coordinateMode;
+
+ const coordinateDirectionSelect = document.getElementById('coordinateDirectionSelect');
+ if (coordinateDirectionSelect) coordinateDirectionSelect.value = state.coordinateDirection;
+
+ const coordinateSnakeToggle = document.getElementById('coordinateSnakeToggle');
+ if (coordinateSnakeToggle) coordinateSnakeToggle.checked = state.coordinateSnake;
+
+ const settingsContainer = document.getElementById('wplace-settings-container');
+ const directionControls = settingsContainer.querySelector('#directionControls');
+ const snakeControls = settingsContainer.querySelector('#snakeControls');
+ const blockControls = settingsContainer.querySelector('#blockControls');
+ Utils.updateCoordinateUI({
+ mode: state.coordinateMode,
+ directionControls,
+ snakeControls,
+ blockControls,
+ });
+
+ const paintUnavailablePixelsToggle = document.getElementById('paintUnavailablePixelsToggle');
+ if (paintUnavailablePixelsToggle) {
+ paintUnavailablePixelsToggle.checked = state.paintUnavailablePixels;
+ }
+
+ const settingsPaintWhiteToggle = settingsContainer.querySelector('#settingsPaintWhiteToggle');
+ if (settingsPaintWhiteToggle) {
+ settingsPaintWhiteToggle.checked = state.paintWhitePixels;
+ }
+
+ const settingsPaintTransparentToggle = settingsContainer.querySelector(
+ '#settingsPaintTransparentToggle'
+ );
+ if (settingsPaintTransparentToggle) {
+ settingsPaintTransparentToggle.checked = state.paintTransparentPixels;
+ }
+
+ const speedSlider = document.getElementById('speedSlider');
+ const speedInput = document.getElementById('speedInput');
+ const speedValue = document.getElementById('speedValue');
+ if (speedSlider) speedSlider.value = state.paintingSpeed;
+ if (speedInput) speedInput.value = state.paintingSpeed;
+ if (speedValue) speedValue.textContent = `pixels`;
+
+ const enableSpeedToggle = document.getElementById('enableSpeedToggle');
+ if (enableSpeedToggle) enableSpeedToggle.checked = CONFIG.PAINTING_SPEED_ENABLED;
+
+ // Painting order UI initialization
+ const paintingOrderSelect = document.getElementById('paintingOrderSelect');
+ if (paintingOrderSelect) paintingOrderSelect.value = state.paintingOrder;
+
+ // Batch mode UI initialization
+ const batchModeSelect = document.getElementById('batchModeSelect');
+ if (batchModeSelect) batchModeSelect.value = state.batchMode;
+
+ const normalBatchControls = document.getElementById('normalBatchControls');
+ const randomBatchControls = document.getElementById('randomBatchControls');
+
+ // Show/hide appropriate controls based on batch mode
+ if (normalBatchControls && randomBatchControls) {
+ if (state.batchMode === 'random') {
+ normalBatchControls.style.display = 'none';
+ randomBatchControls.style.display = 'block';
+ } else {
+ normalBatchControls.style.display = 'block';
+ randomBatchControls.style.display = 'none';
+ }
+ }
+
+ const randomBatchMin = document.getElementById('randomBatchMin');
+ if (randomBatchMin) randomBatchMin.value = state.randomBatchMin;
+
+ const randomBatchMax = document.getElementById('randomBatchMax');
+ if (randomBatchMax) randomBatchMax.value = state.randomBatchMax;
+
+ // AUTO_CAPTCHA_ENABLED is always true - no toggle to set
+
+ const cooldownSlider = document.getElementById('cooldownSlider');
+ const cooldownInput = document.getElementById('cooldownInput');
+ const cooldownValue = document.getElementById('cooldownValue');
+ if (cooldownSlider) cooldownSlider.value = state.cooldownChargeThreshold;
+ if (cooldownInput) cooldownInput.value = state.cooldownChargeThreshold;
+ if (cooldownValue) cooldownValue.textContent = `${Utils.t('charges')}`;
+
+ const overlayOpacitySlider = document.getElementById('overlayOpacitySlider');
+ if (overlayOpacitySlider) overlayOpacitySlider.value = state.overlayOpacity;
+ const overlayOpacityValue = document.getElementById('overlayOpacityValue');
+ if (overlayOpacityValue)
+ overlayOpacityValue.textContent = `${Math.round(state.overlayOpacity * 100)}%`;
+ const enableBlueMarbleToggle = document.getElementById('enableBlueMarbleToggle');
+ if (enableBlueMarbleToggle) enableBlueMarbleToggle.checked = state.blueMarbleEnabled;
+
+ const tokenSourceSelect = document.getElementById('tokenSourceSelect');
+ if (tokenSourceSelect) tokenSourceSelect.value = state.tokenSource;
+
+ const colorAlgorithmSelect = document.getElementById('colorAlgorithmSelect');
+ if (colorAlgorithmSelect) colorAlgorithmSelect.value = state.colorMatchingAlgorithm;
+ const enableChromaPenaltyToggle = document.getElementById('enableChromaPenaltyToggle');
+ if (enableChromaPenaltyToggle) enableChromaPenaltyToggle.checked = state.enableChromaPenalty;
+ const chromaPenaltyWeightSlider = document.getElementById('chromaPenaltyWeightSlider');
+ if (chromaPenaltyWeightSlider) chromaPenaltyWeightSlider.value = state.chromaPenaltyWeight;
+ const chromaWeightValue = document.getElementById('chromaWeightValue');
+ if (chromaWeightValue) chromaWeightValue.textContent = state.chromaPenaltyWeight;
+ const transparencyThresholdInput = document.getElementById('transparencyThresholdInput');
+ if (transparencyThresholdInput)
+ transparencyThresholdInput.value = state.customTransparencyThreshold;
+ const whiteThresholdInput = document.getElementById('whiteThresholdInput');
+ if (whiteThresholdInput) whiteThresholdInput.value = state.customWhiteThreshold;
+ // Notifications UI
+ const notifEnabledToggle = document.getElementById('notifEnabledToggle');
+ if (notifEnabledToggle) notifEnabledToggle.checked = state.notificationsEnabled;
+ const notifOnChargesToggle = document.getElementById('notifOnChargesToggle');
+ if (notifOnChargesToggle) notifOnChargesToggle.checked = state.notifyOnChargesReached;
+ const notifOnlyUnfocusedToggle = document.getElementById('notifOnlyUnfocusedToggle');
+ if (notifOnlyUnfocusedToggle)
+ notifOnlyUnfocusedToggle.checked = state.notifyOnlyWhenUnfocused;
+ const notifIntervalInput = document.getElementById('notifIntervalInput');
+ if (notifIntervalInput) notifIntervalInput.value = state.notificationIntervalMinutes;
+ NotificationManager.resetEdgeTracking();
+ } catch (e) {
+ console.warn('Could not load bot settings:', e);
+ }
+ }
+
+ // Initialize Turnstile generator integration
+ console.log('š WPlace Auto-Image with Turnstile Token Generator loaded');
+ console.log('š Turnstile token generator: ALWAYS ENABLED (Background mode)');
+ console.log('šÆ Manual pixel captcha solving: Available as fallback/alternative');
+ console.log('š± Turnstile widgets: DISABLED - pure background token generation only!');
+
+ // Generate fingerprint string for requests
+ if (!window.WPlaceTokenManager) {
+ console.error('ā WPlaceTokenManager not available - dependency loading issue');
+ return;
+ }
+ const fpStr32 = tokenManager._randStr(32);
+ console.log('š Generated fingerprint string for API requests');
+
+ // Function to enable file operations after initial startup setup is complete
+ function enableFileOperations() {
+ state.initialSetupComplete = true;
+
+ const loadBtn = document.querySelector('#loadBtn');
+ const loadFromFileBtn = document.querySelector('#loadFromFileBtn');
+ const uploadBtn = document.querySelector('#uploadBtn');
+
+ if (loadBtn) {
+ loadBtn.disabled = false;
+ loadBtn.title = '';
+ // Add a subtle animation to indicate the button is now available
+ loadBtn.style.animation = 'pulse 0.6s ease-in-out';
+ setTimeout(() => {
+ if (loadBtn) loadBtn.style.animation = '';
+ }, 600);
+ console.log('ā
Load Progress button enabled after initial setup');
+ }
+
+ if (loadFromFileBtn) {
+ loadFromFileBtn.disabled = false;
+ loadFromFileBtn.title = '';
+ // Add a subtle animation to indicate the button is now available
+ loadFromFileBtn.style.animation = 'pulse 0.6s ease-in-out';
+ setTimeout(() => {
+ if (loadFromFileBtn) loadFromFileBtn.style.animation = '';
+ }, 600);
+ console.log('ā
Load from File button enabled after initial setup');
+ }
+
+ if (uploadBtn) {
+ uploadBtn.disabled = false;
+ uploadBtn.title = '';
+ // Add a subtle animation to indicate the button is now available
+ uploadBtn.style.animation = 'pulse 0.6s ease-in-out';
+ setTimeout(() => {
+ if (uploadBtn) uploadBtn.style.animation = '';
+ }, 600);
+ console.log('ā
Upload Image button enabled after initial setup');
+ }
+
+ // Enable Load Extracted button at the same time as Upload button
+ const loadExtractedBtn = document.getElementById('loadExtractedBtn');
+ if (loadExtractedBtn) {
+ loadExtractedBtn.disabled = false;
+ loadExtractedBtn.title = '';
+ console.log('ā
Load Extracted button enabled after initial setup');
+ }
+
+ // Show a notification that file operations are now available
+ Utils.showAlert(Utils.t('fileOperationsAvailable'), 'success');
+ }
+
+ // Optimized token initialization with better timing and error handling
+ async function initializeTokenGenerator() {
+ // Skip if already have valid token
+ if (isTokenValid()) {
+ console.log('ā
Valid token already available, skipping initialization');
+ updateUI('tokenReady', 'success');
+ enableFileOperations(); // Enable file operations since initial setup is complete
+ return;
+ }
+
+ try {
+ console.log('š§ Initializing Turnstile token generator...');
+ updateUI('initializingToken', 'default');
+
+ console.log('Attempting to load Turnstile script...');
+ await Utils.loadTurnstile();
+ console.log('Turnstile script loaded. Attempting to generate token...');
+
+ // Use TokenManager's handleCaptchaWithRetry method instead
+ const token = await tokenManager.handleCaptchaWithRetry();
+ if (token) {
+ setTurnstileToken(token);
+ console.log('ā
Startup token generated successfully');
+ updateUI('tokenReady', 'success');
+ Utils.showAlert(Utils.t('tokenGeneratorReady'), 'success');
+ enableFileOperations(); // Enable file operations since initial setup is complete
+ } else {
+ console.warn(
+ 'ā ļø Startup token generation failed (no token received), will retry when needed'
+ );
+ updateUI('tokenRetryLater', 'warning');
+ // Still enable file operations even if initial token generation fails
+ // Users can load progress and use manual/hybrid modes
+ enableFileOperations();
+ }
+ } catch (error) {
+ console.error('ā Critical error during Turnstile initialization:', error); // More specific error
+ updateUI('tokenRetryLater', 'warning');
+ // Still enable file operations even if initial setup fails
+ // Users can load progress and use manual/hybrid modes
+ enableFileOperations();
+ // Don't show error alert for initialization failures, just log them
+ }
+ }
+
+ // Load theme preference immediately on startup before creating UI
+ loadThemePreference();
+ applyTheme();
+
+ var pawtect_chunk = null;
+
+ //find module if pawtect_chunk is null
+ pawtect_chunk ??= await findTokenModule("pawtect_wasm_bg.wasm");
+
+ async function createWasmToken(regionX, regionY, payload) {
+ try {
+ // Load the Pawtect module and WASM
+ const mod = await import(new URL('/_app/immutable/chunks/' + pawtect_chunk, location.origin).href);
+ let wasm;
+ try {
+ wasm = await mod._();
+ console.log('ā
WASM initialized successfully');
+ } catch (wasmError) {
+ console.error('ā WASM initialization failed:', wasmError);
+ return null;
+ }
+ try {
+ try {
+ const me = await fetch(`https://backend.wplace.live/me`, { credentials: 'include' }).then(r => r.ok ? r.json() : null);
+ if (me?.id) {
+ mod.i(me.id);
+ console.log('ā
user ID set:', me.id);
+ }
+ } catch { }
+ } catch (userIdError) {
+ console.log('ā ļø Error setting user ID:', userIdError.message);
+ }
+ try {
+ const testUrl = `https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`;
+ if (mod.r) {
+ mod.r(testUrl);
+ console.log('ā
Request URL set:', testUrl);
+ } else {
+ console.log('ā ļø request_url function (mod.r) not available');
+ }
+ } catch (urlError) {
+ console.log('ā ļø Error setting request URL:', urlError.message);
+ }
+
+ // Create test payload
+
+ console.log('š payload:', payload);
+
+ // Encode payload
+ const enc = new TextEncoder();
+ const dec = new TextDecoder();
+ const bodyStr = JSON.stringify(payload);
+ const bytes = enc.encode(bodyStr);
+ console.log('š Payload size:', bytes.length, 'bytes');
+ console.log('š Payload string:', bodyStr);
+
+ // Allocate WASM memory with validation
+ let inPtr;
+ try {
+ if (!wasm.__wbindgen_malloc) {
+ console.error('ā __wbindgen_malloc function not found');
+ return null;
+ }
+
+ inPtr = wasm.__wbindgen_malloc(bytes.length, 1);
+ console.log('ā
WASM memory allocated, pointer:', inPtr);
+
+ // Copy data to WASM memory
+ const wasmBuffer = new Uint8Array(wasm.memory.buffer, inPtr, bytes.length);
+ wasmBuffer.set(bytes);
+ console.log('ā
Data copied to WASM memory');
+ } catch (memError) {
+ console.error('ā Memory allocation error:', memError);
+ return null;
+ }
+
+ // Call the WASM function
+ console.log('š Calling get_pawtected_endpoint_payload...');
+ let outPtr, outLen, token;
+ try {
+ const result = wasm.get_pawtected_endpoint_payload(inPtr, bytes.length);
+ console.log('ā
Function called, result type:', typeof result, result);
+
+ if (Array.isArray(result) && result.length === 2) {
+ [outPtr, outLen] = result;
+ console.log('ā
Got output pointer:', outPtr, 'length:', outLen);
+
+ // Decode the result
+ const outputBuffer = new Uint8Array(wasm.memory.buffer, outPtr, outLen);
+ token = dec.decode(outputBuffer);
+ console.log('ā
Token decoded successfully');
+ } else {
+ console.error('ā Unexpected function result format:', result);
+ return null;
+ }
+ } catch (funcError) {
+ console.error('ā Function call error:', funcError);
+ console.error('Stack trace:', funcError.stack);
+ return null;
+ }
+
+ // Cleanup memory
+ try {
+ if (wasm.__wbindgen_free && outPtr && outLen) {
+ wasm.__wbindgen_free(outPtr, outLen, 1);
+ console.log('ā
Output memory freed');
+ }
+ if (wasm.__wbindgen_free && inPtr) {
+ wasm.__wbindgen_free(inPtr, bytes.length, 1);
+ console.log('ā
Input memory freed');
+ }
+ } catch (cleanupError) {
+ console.log('ā ļø Cleanup warning:', cleanupError.message);
+ }
+
+ // Display results
+ console.log('');
+ console.log('š SUCCESS!');
+ console.log('š Full token:');
+ console.log(token);
+ return token;
+ } catch (error) {
+ console.error('ā Failed to generate fp parameter:', error);
+ return null;
+ }
+ }
+
+ async function findTokenModule(str) {
+ console.log('š Searching for wasm Module...');
+ const links = Array.from(document.querySelectorAll('link[rel="modulepreload"][href$=".js"]'));
+
+ for (const link of links) {
+ try {
+ const url = new URL(link.getAttribute("href"), location.origin).href;
+ const code = await fetch(url).then(r => r.text());
+ if (code.includes(str)) {
+ console.log('ā
Found wasm Module...');
+ return url.split('/').pop();
+ }
+ } catch { }
+ }
+ console.error(`ā Could not find Pawtect chunk: `, error);
+ }
+
+ async function purchase(type) {
+ // loadThemePreference()
+ let id;
+ let chargeMultiplier;
+ if (type === "max_charges") {
+ id = 70;
+ chargeMultiplier = 5;
+ } else if (type === "paint_charges") {
+ id = 80;
+ chargeMultiplier = 30;
+ } else {
+ console.error("Error: Invalid purchase type provided.");
+ return;
+ }
+ const { droplets } = await WPlaceService.getCharges();
+ console.log("There are currently : ", droplets, "droplets.");
+ try {
+ const amounts = Math.floor(droplets / 500);
+ if (amounts < 1) {
+ console.log("Not enough droplets to purchase.");
+ return;
+ }
+ const payload = {
+ "product": {
+ "id": id,
+ "amount": amounts
+ }
+ };
+ const res = await fetch("https://backend.wplace.live/purchase", {
+ method: "POST",
+ headers: {
+ "Content-Type": "text/plain;charset=UTF-8"
+ },
+ body: JSON.stringify(payload),
+ credentials: "include"
+ });
+ // POST request completed
+ const { droplets: newDroplets } = await WPlaceService.getCharges();
+ if (droplets != newDroplets) {
+ console.log("Successfully bought", amounts * chargeMultiplier, type.replace('_', ' '), ".");
+ return 2;
+ } else {
+ console.log("Failed to buy charges");
+ return 1;
+ }
+ } catch (e) {
+ console.error("An error occurred during the purchase:", e);
+ return 0;
+ }
+ }
+ async function swapAccountTrigger(token) {
+ // STRICT GUARD: Only allow account switching during active painting sessions OR controlled refresh
+ if (!state.running && !state.isFetchingAllAccounts) {
+ console.warn('š Account switching blocked - only allowed during active painting or controlled refresh');
+ console.warn('š Current state.running:', state.running, 'state.isFetchingAllAccounts:', state.isFetchingAllAccounts);
+ return false;
+ }
+
+ localStorage.removeItem("lp");
+ if (!token) {
+ console.error('ā Cannot swap account: token is null or undefined');
+ return false;
+ }
+
+ console.log(`š Triggering account swap with token: ${token.substring(0, 20)}...`);
+ console.log('š¤ Sending setCookie message to extension...');
+
+ try {
+ window.postMessage({
+ source: 'my-userscript',
+ type: 'setCookie',
+ value: token
+ }, '*');
+
+ console.log('ā
setCookie message sent successfully');
+ return true;
+ } catch (error) {
+ console.error('ā Failed to send setCookie message:', error);
+ return false;
+ }
+ }
+ async function getAccounts() {
+ return new Promise((resolve, reject) => {
+ console.log("Requesting accounts from extension...");
+
+ // Ask extension for accounts
+ window.postMessage({
+ source: "my-userscript",
+ type: "getAccounts"
+ }, "*");
+
+ function handler(event) {
+ if (event.source !== window) return;
+ if (event.data.source !== "extension") return;
+ if (event.data.type === "accountsData") {
+ // Remove listener when we get the response
+ window.removeEventListener("message", handler);
+
+ try {
+ localStorage.setItem("accounts", JSON.stringify(event.data.accounts));
+ console.log("ā
Accounts saved to localStorage:", event.data.accounts);
+ } catch (e) {
+ console.error("ā Failed to save accounts:", e);
+ }
+ resolve(event.data.accounts);
+ }
+ }
+ window.addEventListener("message", handler);
+ });
+ }
+
+ async function fetchAccount() {
+ const { ID, Charges, Max, Droplets } = await WPlaceService.fetchCheck();
+ console.log("User's ID :", ID);
+ console.log("User's Charges :", Charges, "/", Max);
+ console.log("User's Droplets :", Droplets);
+ }
+
+ async function fetchAllAccountDetails() {
+ if (state.isFetchingAllAccounts) {
+ Utils.showAlert("Already fetching account details.", "warning");
+ return;
+ }
+
+ state.isFetchingAllAccounts = true;
+
+ const refreshBtn = document.getElementById('refreshAllAccountsBtn');
+ if (refreshBtn) {
+ refreshBtn.innerHTML = '';
+ refreshBtn.disabled = true;
+ }
+
+ const accountsListArea = document.getElementById('accountsListArea');
+ if (accountsListArea) {
+ accountsListArea.innerHTML = `Loading accounts...
`;
+ }
+
+ try {
+ // First, get accounts from the extension
+ console.log(`š [FETCH] Requesting accounts from extension...`);
+ try {
+ await getAccounts();
+ console.log(`ā
[FETCH] Successfully retrieved accounts from extension`);
+ } catch (error) {
+ console.warn(`ā ļø [FETCH] Failed to get accounts from extension:`, error);
+ // Continue anyway in case we have cached accounts
+ }
+
+ // Load accounts using the new AccountManager
+ await accountManager.loadAccounts();
+
+ console.log(`ā
[FETCH] Loaded ${accountManager.getAccountCount()} accounts from storage`);
+
+ // Debug: Check if we actually have accounts
+ if (accountManager.getAccountCount() === 0) {
+ console.warn(`ā ļø [FETCH] No accounts found in storage. Check localStorage 'accounts' and chrome.storage 'infoAccounts'`);
+
+ // Check localStorage for accounts
+ const localStorageAccounts = JSON.parse(localStorage.getItem("accounts")) || [];
+ console.log(`š [DEBUG] localStorage accounts:`, localStorageAccounts);
+
+ // Render empty state and return early
+ renderAccountsList();
+ return;
+ }
+
+ // Now fetch fresh data for each account
+ const accounts = accountManager.getAllAccounts();
+ if (accounts.length > 0) {
+ console.log(`š [FETCH] Fetching fresh data for ${accounts.length} accounts...`);
+
+ // Remember the current account so we can switch back
+ const originalCurrentAccount = accounts.find(acc => acc.isCurrent);
+
+ for (let i = 0; i < accounts.length; i++) {
+ const account = accounts[i];
+ console.log(`š [FETCH] Fetching data for account ${i + 1}: ${account.displayName}`);
+
+ try {
+ // Switch to this account temporarily to fetch its data
+ console.log(`š [FETCH] Switching to ${account.displayName} to fetch fresh data...`);
+ await switchToSpecificAccount(account.token, account.displayName);
+ // await Utils.sleep(500); // Small delay to ensure switch takes effect
+
+ // Fetch fresh account details
+ const accountData = await WPlaceService.getCharges();
+ const accountInfo = await WPlaceService.fetchCheck();
+
+ // Update account with fresh data
+ accountManager.updateAccountData(account.token, {
+ ID: accountData.id || accountInfo.ID,
+ Charges: Math.floor(accountData.charges || 0),
+ Max: Math.floor(accountData.max || 0),
+ Droplets: Math.floor(accountData.droplets || 0),
+ displayName: accountInfo.Username || accountInfo.name || account.displayName
+ });
+
+ console.log(`ā
[FETCH] Updated ${account.displayName}: ā”${Math.floor(accountData.charges)}/${Math.floor(accountData.max)} š§${Math.floor(accountData.droplets)}`);
+
+ } catch (error) {
+ console.warn(`ā ļø [FETCH] Failed to fetch data for ${account.displayName}:`, error);
+ }
+ }
+
+ // Switch back to the original current account if there was one
+ if (originalCurrentAccount) {
+ console.log(`š [FETCH] Switching back to original current account: ${originalCurrentAccount.displayName}`);
+ try {
+ await switchToSpecificAccount(originalCurrentAccount.token, originalCurrentAccount.displayName);
+ //await Utils.sleep(300);
+
+ // Mark it as current again
+ accountManager.updateAccountData(originalCurrentAccount.token, {
+ isCurrent: true
+ });
+ } catch (error) {
+ console.warn(`ā ļø [FETCH] Failed to switch back to original account:`, error);
+ }
+ }
+
+ console.log(`šÆ [FETCH] Completed fetching fresh data for all accounts`);
+ }
+
+ // Render the accounts list with fresh data
+ renderAccountsList();
+
+ } catch (error) {
+ console.error('ā [FETCH] Error fetching account details:', error);
+ if (accountsListArea) {
+ accountsListArea.innerHTML = `Error loading accounts.
`;
+ }
+ } finally {
+ state.isFetchingAllAccounts = false;
+ if (refreshBtn) {
+ refreshBtn.innerHTML = '';
+ refreshBtn.disabled = false;
+ }
+ }
+ }
+
+ // Function to update current account charges in the account list
+ async function updateCurrentAccountInList() {
+ if (accountManager.getAccountCount() === 0) return;
+
+ // Find current account in the list and update its charges
+ const currentAccount = accountManager.getCurrentAccount();
+ if (currentAccount) {
+ const { charges, cooldown, droplets } = await WPlaceService.getCharges();
+ state.displayCharges = Math.floor(charges);
+ state.preciseCurrentCharges = charges;
+ await updateStats();
+
+ // Update the current account data in AccountManager
+ accountManager.updateAccountData(currentAccount.token, {
+ Charges: Math.floor(state.displayCharges || state.preciseCurrentCharges || 0),
+ Max: state.maxCharges,
+ Droplets: Math.floor(droplets)
+ });
+
+ // Re-render the account list to show updated charges
+ renderAccountsList();
+ }
+ }
+
+ // Function to update current account spotlight when switching during painting
+ async function updateCurrentAccountSpotlight() {
+ if (accountManager.getAccountCount() === 0) return;
+ // await Utils.sleep(500); // Wait a bit for the switch to take effect
+ try {
+ const currentAccountData = await WPlaceService.getCharges();
+ console.log("Current account after switch:", currentAccountData);
+ console.log(`š Switched to account with ID: ${currentAccountData.id}`);
+
+ // Find the current account in AccountManager and update it
+ const accounts = accountManager.getAllAccounts();
+ const currentAccount = accounts.find(acc => acc.ID === currentAccountData.id);
+
+ if (currentAccount) {
+ const currentAccountInfo = await WPlaceService.fetchCheck();
+
+ // Update account data in AccountManager
+ accountManager.updateAccountData(currentAccount.token, {
+ isCurrent: true,
+ Charges: Math.floor(currentAccountData.charges),
+ Max: Math.floor(currentAccountData.max),
+ Droplets: Math.floor(currentAccountData.droplets),
+ displayName: currentAccountInfo.Username || currentAccountInfo.name || currentAccount.displayName
+ });
+
+ console.log(`šÆ Updated current account spotlight: ${currentAccount.displayName}`);
+
+ // Check for autobuy after account is loaded
+ if (CONFIG.autoBuyToggle && Math.floor(currentAccountData.droplets) > 500) {
+ console.log(`š° Account has ${Math.floor(currentAccountData.droplets)} droplets (>500), triggering autobuy...`);
+ try {
+ const purchaseResult = await purchase(CONFIG.autoBuy);
+ if (purchaseResult == 2) {
+ console.log('ā
Autobuy successful after account switch');
+ // Update charges after purchase
+ const updatedCharges = await WPlaceService.getCharges();
+ accountManager.updateAccountData(currentAccount.token, {
+ Charges: Math.floor(updatedCharges.charges),
+ Droplets: Math.floor(updatedCharges.droplets)
+ });
+ // Re-render account list to show updated status after purchase
+ renderAccountsList();
+ } else {
+ console.log('ā Autobuy failed after account switch');
+ }
+ } catch (error) {
+ console.error('ā Error during autobuy after account switch:', error);
+ }
+ } else if (CONFIG.autoBuyToggle) {
+ console.log(`š° Account has ${Math.floor(currentAccountData.droplets)} droplets (ā¤500), skipping autobuy`);
+ }
+
+ // Re-render the account list to show new current account
+ renderAccountsList();
+
+ console.log(`š PRESERVING currentActiveIndex: ${state.currentActiveIndex} (do not recalculate from isCurrent flag)`);
+ console.log(`ļæ½ Account at currentActiveIndex ${state.currentActiveIndex}: ${state.originalAccountOrder[state.currentActiveIndex]?.displayName} (ID ${state.originalAccountOrder[state.currentActiveIndex]?.orderId})`);
+
+ // Update accountIndex to match original array position
+ const originalArrayIndex = state.allAccountsInfo.findIndex(acc => acc.isCurrent);
+ if (originalArrayIndex !== -1) {
+ state.accountIndex = originalArrayIndex;
+ }
+
+ console.log(`š Final state: activeIndex=${state.currentActiveIndex}, accountIndex=${state.accountIndex}, orderId=${newCurrentAccount.orderId}`);
+
+ console.warn(`ā ļø Could not find account ID ${newCurrentAccount.orderId} in switching order`);
+
+ console.warn(`ā ļø Could not find switched account with ID ${currentAccountData.id} in account list`);
+ }
+
+ // Re-render the account list to show new current account
+ renderAccountsList();
+
+ } catch (error) {
+ console.warn('ā ļø Failed to update account spotlight:', error);
+ }
+ }
+
+ function renderAccountsList() {
+ const accountsListArea = document.getElementById('accountsListArea');
+ if (!accountsListArea) return;
+
+ accountsListArea.innerHTML = '';
+
+ const accounts = accountManager.getAllAccounts();
+ console.log(`š [RENDER] Rendering ${accounts.length} accounts`);
+
+ // Debug: Log account data to see what we're working with
+ accounts.forEach((account, index) => {
+ console.log(`š [RENDER] Account ${index + 1}: ${account.displayName} - ā”${account.Charges}/${account.Max} š§${account.Droplets} ${account.isCurrent ? '(CURRENT)' : ''}`);
+ });
+
+ if (accounts.length === 0) {
+ // Don't show any placeholder - just leave empty
+ console.log(`š [RENDER] No accounts to display`);
+ return;
+ }
+
+ accounts.forEach((account, index) => {
+ const item = createAccountItem(account, index);
+ accountsListArea.appendChild(item);
+ });
+
+ console.log(`ā
[RENDER] Successfully rendered ${accounts.length} accounts`);
+ }
+
+ function createAccountItem(account, index) {
+ const item = document.createElement('div');
+
+ // Determine if this is the current account
+ const isCurrentAccount = account.isCurrent;
+ const isNextInSequence = accountManager.currentIndex !== -1 &&
+ ((accountManager.currentIndex + 1) % accountManager.getAccountCount()) === index;
+
+ let itemClasses = 'wplace-account-item';
+ if (isCurrentAccount) {
+ itemClasses += ' current';
+ } else if (isNextInSequence) {
+ itemClasses += ' next-in-sequence';
+ }
+
+ item.className = itemClasses;
+
+ // Create ordering number element with 1-based index for user display
+ const orderNumber = document.createElement('div');
+ orderNumber.className = 'wplace-account-number';
+ orderNumber.textContent = index + 1; // Show 1-based index for user-friendly display
+
+ // Add visual indicator for current position in sequence
+ if (isCurrentAccount) {
+ orderNumber.style.background = '#2ecc71'; // Green color
+ orderNumber.style.color = 'white';
+ orderNumber.style.boxShadow = '0 0 10px rgba(46, 204, 113, 0.8)';
+ } else if (isNextInSequence) {
+ orderNumber.style.background = '#f39c12'; // Orange color for next
+ orderNumber.style.color = 'white';
+ orderNumber.style.boxShadow = '0 0 8px rgba(243, 156, 18, 0.6)';
+ }
+
+ // Create account details
+ const details = document.createElement('div');
+ details.className = 'wplace-account-details';
+
+ const accountName = document.createElement('div');
+ accountName.className = 'wplace-account-name';
+ const displayName = account.displayName || account.name || `Account ${index + 1}`;
+ accountName.textContent = displayName;
+ accountName.title = displayName;
+
+ const accountStats = document.createElement('div');
+ accountStats.className = 'wplace-account-stats';
+
+ // Safely handle undefined values
+ const charges = account.Charges !== undefined ? Math.floor(account.Charges) : 0;
+ const max = account.Max !== undefined ? Math.floor(account.Max) : 0;
+ const droplets = account.Droplets !== undefined ? Math.floor(account.Droplets) : 0;
+
+ accountStats.innerHTML = `
+ ${charges}/${max}
+ ${droplets}
+ `;
+
+ details.appendChild(accountName);
+ details.appendChild(accountStats);
+
+ // Add status indicators
+ const status = document.createElement('div');
+ status.className = 'wplace-account-status';
+ if (isCurrentAccount) {
+ status.innerHTML = '';
+ } else if (isNextInSequence) {
+ status.innerHTML = '';
+ }
+
+ item.appendChild(orderNumber);
+ item.appendChild(details);
+ item.appendChild(status);
+
+ return item;
+ }
+
+ // SIMPLIFIED ACCOUNT SWITCHING
+ async function switchToNextAccount(accounts) {
+ console.log(`š [SWITCH] Starting account switch`);
+
+ // Validate we have accounts
+ if (accountManager.getAccountCount() === 0) {
+ console.error('ā No accounts available for switching');
+ return false;
+ }
+
+ // Get next account using simplified manager
+ const nextAccount = accountManager.switchToNext();
+ if (!nextAccount) {
+ console.error('ā Failed to get next account');
+ return false;
+ }
+
+ console.log(`šÆ [SWITCH] Switching to: ${nextAccount.displayName} (index ${accountManager.currentIndex})`);
+
+ // Ensure token exists
+ if (!nextAccount.token) {
+ console.error(`ā Missing token for account: ${nextAccount.displayName}`);
+ return false;
+ }
+
+ console.log(`ļæ½ [SWITCH] Using token: ${nextAccount.token.substring(0, 20)}...`);
+
+ // Perform the account switch
+ try {
+ await swapAccountTrigger(nextAccount.token);
+
+ // Update account index for backward compatibility
+ state.accountIndex = accountManager.currentIndex;
+
+ console.log(`ā
[SWITCH] Successfully switched to ${nextAccount.displayName}`);
+
+ // Wait a moment for the switch to fully complete
+ // await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // Update the account status and UI after successful switch
+ await updateCurrentAccountInList();
+
+ return true;
+ } catch (error) {
+ console.error('ā [SWITCH] Account switch failed:', error);
+ return false;
+ }
+ }
+
+ // SIMPLIFIED helper function for specific account switching
+ async function switchToSpecificAccount(token, accountName) {
+ console.log(`š [SPECIFIC SWITCH] Attempting to switch to account: ${accountName}`);
+ console.log(`š [SPECIFIC SWITCH] Using token: ${token.substring(0, 20)}...`);
+
+ await swapAccountTrigger(token);
+
+ let maxRetries = 20;
+ let retryCount = 0;
+ let swapSuccess = false;
+
+ while (!swapSuccess && retryCount < maxRetries) {
+ console.log(`ā³ [SPECIFIC SWITCH] Waiting for account swap... (Attempt ${retryCount + 1}/${maxRetries})`);
+ //await new Promise(resolve => setTimeout(resolve, 1000));
+
+ try {
+ // await fetchAccount();
+ console.log('ā
[SPECIFIC SWITCH] Account swap confirmed.');
+ swapSuccess = true;
+ } catch (error) {
+ console.warn('ā [SPECIFIC SWITCH] Account swap not yet successful. Retrying...', error);
+ retryCount++;
+
+ if (retryCount % 5 === 0) {
+ console.log('š [SPECIFIC SWITCH] Re-triggering account swap...');
+ await swapAccountTrigger(token);
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+ }
+ }
+
+ if (swapSuccess) {
+ const { charges, cooldown } = await WPlaceService.getCharges();
+ state.displayCharges = Math.floor(charges);
+ state.preciseCurrentCharges = charges;
+ state.cooldown = cooldown;
+ Utils.performSmartSave();
+ await updateStats();
+
+ // Update account data in manager
+ accountManager.updateAccountData({ charges, cooldown });
+
+ // Update the account status and UI after successful switch
+ await updateCurrentAccountSpotlight();
+
+ console.log(`ā
[SPECIFIC SWITCH] Successfully switched to ${accountName} with ${Math.floor(charges)} charges`);
+ return true;
+ } else {
+ console.error(`ā [SPECIFIC SWITCH] Failed to swap to ${accountName} after multiple retries.`);
+ return false;
+ }
+ }
+
+ // Wait for dependencies before initializing UI
+ async function waitForDependenciesAndInitialize() {
+ // Wait for all global managers to be available
+ let attempts = 0;
+ const maxAttempts = 50; // 5 seconds max wait
+
+ while (attempts < maxAttempts) {
+ const managers = {
+ utilsManager: window.globalUtilsManager,
+ imageProcessor: window.globalImageProcessor,
+ overlayManager: window.globalOverlayManager,
+ tokenManager: window.globalTokenManager
+ };
+
+ const missingManagers = Object.entries(managers)
+ .filter(([name, manager]) => !manager)
+ .map(([name]) => name);
+
+ if (missingManagers.length === 0) {
+ console.log('ā
All global managers are available, initializing UI...');
+ break;
+ }
+
+ console.log(`ā³ Waiting for managers: ${missingManagers.join(', ')} (attempt ${attempts + 1}/${maxAttempts})`);
+ await new Promise(resolve => setTimeout(resolve, 100));
+ attempts++;
+ }
+
+ if (attempts >= maxAttempts) {
+ console.warn('ā ļø Some global managers not available after waiting, proceeding anyway...');
+ console.log('Available managers:', {
+ utilsManager: !!window.globalUtilsManager,
+ imageProcessor: !!window.globalImageProcessor,
+ overlayManager: !!window.globalOverlayManager,
+ tokenManager: !!window.globalTokenManager
+ });
+ }
+
+ return createUI();
+ }
+
+ waitForDependenciesAndInitialize().then(() => {
+ // Generate token automatically after UI is ready
+ setTimeout(initializeTokenGenerator, 1000);
+
+ // Quick initial account load from cache
+ setTimeout(async () => {
+ console.log('š Initial account load from cache...');
+ try {
+ await accountManager.loadAccounts();
+ if (accountManager.getAccountCount() > 0) {
+ console.log(`ā
Loaded ${accountManager.getAccountCount()} cached accounts`);
+ renderAccountsList();
+ } else {
+ console.log('š No cached accounts found');
+ }
+ } catch (error) {
+ console.warn('ā ļø Initial account load failed:', error);
+ }
+ }, 500);
+
+ // Auto-refresh account list on startup (with extension communication)
+ setTimeout(() => {
+ console.log('š Auto-refreshing account list on startup...');
+ fetchAllAccountDetails();
+ }, 2000);
+
+ // Attach advanced color matching listeners (resize dialog)
+ const advancedInit = () => {
+ const chromaSlider = document.getElementById('chromaPenaltyWeightSlider');
+ const chromaValue = document.getElementById('chromaWeightValue');
+ const resetBtn = document.getElementById('resetAdvancedColorBtn');
+ const algoSelect = document.getElementById('colorAlgorithmSelect');
+ const chromaToggle = document.getElementById('enableChromaPenaltyToggle');
+ const transInput = document.getElementById('transparencyThresholdInput');
+ const whiteInput = document.getElementById('whiteThresholdInput');
+ const ditherToggle = document.getElementById('enableDitheringToggle');
+ if (algoSelect)
+ algoSelect.addEventListener('change', (e) => {
+ state.colorMatchingAlgorithm = e.target.value;
+ saveBotSettings();
+ _updateResizePreview();
+ });
+ if (chromaToggle)
+ chromaToggle.addEventListener('change', (e) => {
+ state.enableChromaPenalty = e.target.checked;
+ saveBotSettings();
+ _updateResizePreview();
+ });
+ if (chromaSlider && chromaValue)
+ chromaSlider.addEventListener('input', (e) => {
+ state.chromaPenaltyWeight = parseFloat(e.target.value) || 0.15;
+ chromaValue.textContent = state.chromaPenaltyWeight.toFixed(2);
+ saveBotSettings();
+ _updateResizePreview();
+ });
+ if (transInput)
+ transInput.addEventListener('change', (e) => {
+ const v = parseInt(e.target.value, 10);
+ if (!isNaN(v) && v >= 0 && v <= 255) {
+ state.customTransparencyThreshold = v;
+ CONFIG.TRANSPARENCY_THRESHOLD = v;
+ saveBotSettings();
+ _updateResizePreview();
+ }
+ });
+ if (whiteInput)
+ whiteInput.addEventListener('change', (e) => {
+ const v = parseInt(e.target.value, 10);
+ if (!isNaN(v) && v >= 200 && v <= 255) {
+ state.customWhiteThreshold = v;
+ CONFIG.WHITE_THRESHOLD = v;
+ saveBotSettings();
+ _updateResizePreview();
+ }
+ });
+ if (ditherToggle)
+ ditherToggle.addEventListener('change', (e) => {
+ state.ditheringEnabled = e.target.checked;
+ saveBotSettings();
+ _updateResizePreview();
+ });
+ if (resetBtn)
+ resetBtn.addEventListener('click', () => {
+ state.colorMatchingAlgorithm = 'lab';
+ state.enableChromaPenalty = true;
+ state.chromaPenaltyWeight = 0.15;
+ state.customTransparencyThreshold = CONFIG.TRANSPARENCY_THRESHOLD = 100;
+ state.customWhiteThreshold = CONFIG.WHITE_THRESHOLD = 250;
+ saveBotSettings();
+ const a = document.getElementById('colorAlgorithmSelect');
+ if (a) a.value = 'lab';
+ const ct = document.getElementById('enableChromaPenaltyToggle');
+ if (ct) ct.checked = true;
+ if (chromaSlider) chromaSlider.value = 0.15;
+ if (chromaValue) chromaValue.textContent = '0.15';
+ if (transInput) transInput.value = 100;
+ if (whiteInput) whiteInput.value = 250;
+ _updateResizePreview();
+ Utils.showAlert(Utils.t('advancedColorSettingsReset'), 'success');
+ });
+ };
+ // Delay to ensure resize UI built
+ setTimeout(advancedInit, 500);
+
+ // Add cleanup on page unload
+ window.addEventListener('beforeunload', () => {
+ Utils.cleanupTurnstile();
+ });
+ });
+})();
+
+// === Async coordinate generation (off-main-thread) ===
+async function generateCoordinatesAsync(
+ width,
+ height,
+ mode,
+ direction,
+ snake,
+ blockWidth,
+ blockHeight,
+ startFromX = 0,
+ startFromY = 0
+) {
+ try {
+ if (typeof Worker === 'undefined') {
+ // Environment does not support Web Workers ā fall back
+ return generateCoordinates(
+ width,
+ height,
+ mode,
+ direction,
+ snake,
+ blockWidth,
+ blockHeight,
+ startFromX,
+ startFromY
+ );
+ }
+
+ const workerCode = `self.onmessage = function(e) {
+ const { width, height, mode, direction, snake, blockWidth, blockHeight, startFromX, startFromY } = e.data || {};
+
+ function generate() {
+ let coords = [];
+
+ // Determine traversal direction
+ let xStart, xEnd, xStep;
+ let yStart, yEnd, yStep;
+ switch (direction) {
+ case 'top-left':
+ xStart = 0; xEnd = width; xStep = 1;
+ yStart = 0; yEnd = height; yStep = 1;
+ break;
+ case 'top-right':
+ xStart = width - 1; xEnd = -1; xStep = -1;
+ yStart = 0; yEnd = height; yStep = 1;
+ break;
+ case 'bottom-left':
+ xStart = 0; xEnd = width; xStep = 1;
+ yStart = height - 1; yEnd = -1; yStep = -1;
+ break;
+ case 'bottom-right':
+ xStart = width - 1; xEnd = -1; xStep = -1;
+ yStart = height - 1; yEnd = -1; yStep = -1;
+ break;
+ default:
+ throw new Error('Unknown direction: ' + direction);
+ }
+
+ if (mode === 'rows') {
+ let rowIndex = 0;
+ for (let y = yStart; y !== yEnd; y += yStep) {
+ if (snake && rowIndex % 2 !== 0) {
+ for (let x = xEnd - xStep; x !== xStart - xStep; x -= xStep) {
+ coords.push([x, y]);
+ }
+ } else {
+ for (let x = xStart; x !== xEnd; x += xStep) {
+ coords.push([x, y]);
+ }
+ }
+ rowIndex++;
+ }
+ } else if (mode === 'columns') {
+ let colIndex = 0;
+ for (let x = xStart; x !== xEnd; x += xStep) {
+ if (snake && colIndex % 2 !== 0) {
+ for (let y = yEnd - yStep; y !== yStart - yStep; y -= yStep) {
+ coords.push([x, y]);
+ }
+ } else {
+ for (let y = yStart; y !== yEnd; y += yStep) {
+ coords.push([x, y]);
+ }
+ }
+ colIndex++;
+ }
+ } else if (mode === 'circle-out') {
+ const cx = Math.floor(width / 2);
+ const cy = Math.floor(height / 2);
+ const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
+ for (let r = 0; r <= maxRadius; r++) {
+ for (let y = cy - r; y <= cy + r; y++) {
+ for (let x = cx - r; x <= cx + r; x++) {
+ if (x >= 0 && x < width && y >= 0 && y < height) {
+ const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
+ if (dist === r) coords.push([x, y]);
+ }
+ }
+ }
+ }
+ } else if (mode === 'circle-in') {
+ const cx = Math.floor(width / 2);
+ const cy = Math.floor(height / 2);
+ const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
+ for (let r = maxRadius; r >= 0; r--) {
+ for (let y = cy - r; y <= cy + r; y++) {
+ for (let x = cx - r; x <= cx + r; x++) {
+ if (x >= 0 && x < width && y >= 0 && y < height) {
+ const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
+ if (dist === r) coords.push([x, y]);
+ }
+ }
+ }
+ }
+ } else if (mode === 'blocks' || mode === 'shuffle-blocks') {
+ const blocks = [];
+ for (let by = 0; by < height; by += blockHeight) {
+ for (let bx = 0; bx < width; bx += blockWidth) {
+ const block = [];
+ for (let y = by; y < Math.min(by + blockHeight, height); y++) {
+ for (let x = bx; x < Math.min(bx + blockWidth, width); x++) {
+ block.push([x, y]);
+ }
+ }
+ blocks.push(block);
+ }
+ }
+ if (mode === 'shuffle-blocks') {
+ for (let i = blocks.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const tmp = blocks[i];
+ blocks[i] = blocks[j];
+ blocks[j] = tmp;
+ }
+ }
+ for (const block of blocks) {
+ for (let i = 0; i < block.length; i++) {
+ coords.push(block[i]);
+ }
+ }
+ } else {
+ throw new Error('Unknown mode: ' + mode);
+ }
+
+ // Resume from specified position if provided
+ if (startFromX > 0 || startFromY > 0) {
+ let startIndex = -1;
+ for (let i = 0; i < coords.length; i++) {
+ const c = coords[i];
+ if (c[0] === startFromX && c[1] === startFromY) {
+ startIndex = i;
+ break;
+ }
+ }
+ if (startIndex >= 0) {
+ coords = coords.slice(startIndex);
+ }
+ }
+
+ return coords;
+ }
+
+ try {
+ const coords = generate();
+ self.postMessage({ ok: true, coords });
+ } catch (err) {
+ self.postMessage({ ok: false, error: err && (err.message || String(err)) });
+ }
+};`;
+
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
+ const url = URL.createObjectURL(blob);
+
+ return await new Promise((resolve) => {
+ const worker = new Worker(url);
+ const cleanup = () => {
+ URL.revokeObjectURL(url);
+ try { worker.terminate(); } catch (e) { }
+ };
+
+ worker.onmessage = (e) => {
+ const { ok, coords, error } = e.data || {};
+ cleanup();
+ if (ok && Array.isArray(coords)) {
+ resolve(coords);
+ } else {
+ console.warn('Coordinate worker failed, falling back to sync:', error);
+ resolve(
+ generateCoordinates(
+ width,
+ height,
+ mode,
+ direction,
+ snake,
+ blockWidth,
+ blockHeight,
+ startFromX,
+ startFromY
+ )
+ );
+ }
+ };
+
+ worker.onerror = (err) => {
+ console.warn('Coordinate worker error, falling back to sync:', err && (err.message || err));
+ cleanup();
+ resolve(
+ generateCoordinates(
+ width,
+ height,
+ mode,
+ direction,
+ snake,
+ blockWidth,
+ blockHeight,
+ startFromX,
+ startFromY
+ )
+ );
+ };
+
+ worker.postMessage({
+ width,
+ height,
+ mode,
+ direction,
+ snake,
+ blockWidth,
+ blockHeight,
+ startFromX,
+ startFromY,
+ });
+ });
+ } catch (e) {
+ console.warn('Failed to start coordinate worker, using sync generation:', e);
+ return generateCoordinates(
+ width,
+ height,
+ mode,
+ direction,
+ snake,
+ blockWidth,
+ blockHeight,
+ startFromX,
+ startFromY
+ );
+ }
+}
diff --git a/Extension/scripts/utils-manager.js b/Extension/scripts/utils-manager.js
index e0f458a..64581f8 100644
--- a/Extension/scripts/utils-manager.js
+++ b/Extension/scripts/utils-manager.js
@@ -673,7 +673,7 @@ class WPlaceUtilsManager {
}
buildProgressData() {
- return {
+ const result = {
timestamp: Date.now(),
version: '2.2',
state: {
@@ -691,16 +691,42 @@ class WPlaceUtilsManager {
blockHeight: window.state.blockHeight,
availableColors: window.state.availableColors,
},
- imageData: window.state.imageData
- ? {
- width: window.state.imageData.width,
- height: window.state.imageData.height,
- pixels: Array.from(window.state.imageData.pixels),
- totalPixels: window.state.imageData.totalPixels,
- }
- : null,
- paintedMapPacked: this.buildPaintedMapPacked(),
+ imageData: null,
+ paintedMapPacked: null,
};
+
+ // Decide how to store image pixels
+ if (window.state.imageData) {
+ const img = window.state.imageData;
+ const width = img.width;
+ const height = img.height;
+ const totalPixels = img.totalPixels;
+ const pixels = img.pixels;
+
+ const USE_IDB_THRESHOLD = 500000; // ~0.5 MB raw RGBA (before JSON expansion)
+
+ if (pixels && pixels.length) {
+ const bytes = pixels.length; // Uint8ClampedArray length equals byte size
+ if (bytes > USE_IDB_THRESHOLD) {
+ // Store pixels in IndexedDB and keep only the reference in save payload
+ const ref = this.generatePixelsRef(width, height);
+ try {
+ // Fire-and-forget async persist
+ this.storePixelsInIndexedDB(ref, { width, height, totalPixels, pixels });
+ } catch (e) {
+ console.warn('Failed to schedule IDB pixel store:', e);
+ }
+ result.imageData = { width, height, totalPixels, pixelsRef: ref, pixelsBackend: 'idb' };
+ } else {
+ result.imageData = { width, height, pixels: Array.from(pixels), totalPixels };
+ }
+ } else {
+ result.imageData = { width, height, totalPixels, pixels: null };
+ }
+ }
+
+ result.paintedMapPacked = this.buildPaintedMapPacked();
+ return result;
}
migrateProgress(saved) {
@@ -727,8 +753,37 @@ class WPlaceUtilsManager {
saveProgress() {
try {
const progressData = this.buildProgressData();
- localStorage.setItem('wplace-bot-progress', JSON.stringify(progressData));
- return true;
+ try {
+ localStorage.setItem('wplace-bot-progress', JSON.stringify(progressData));
+ return true;
+ } catch (e) {
+ // Strip heavy pixel data and retry if quota exceeded
+ if (progressData && progressData.imageData && progressData.imageData.pixels) {
+ const slim = {
+ ...progressData,
+ imageData: {
+ width: progressData.imageData.width,
+ height: progressData.imageData.height,
+ totalPixels: progressData.imageData.totalPixels,
+ pixelsStripped: true,
+ }
+ };
+ try {
+ localStorage.setItem('wplace-bot-progress', JSON.stringify(slim));
+ console.warn('Saved progress without raw pixel data due to storage quota limits.');
+ return true;
+ } catch (e2) {
+ try {
+ sessionStorage.setItem('wplace-bot-progress', JSON.stringify(slim));
+ console.warn('Saved progress to sessionStorage without pixel data due to storage quota limits.');
+ return true;
+ } catch (e3) {
+ throw e2;
+ }
+ }
+ }
+ throw e;
+ }
} catch (error) {
console.error('Error saving progress:', error);
return false;
@@ -799,7 +854,7 @@ class WPlaceUtilsManager {
window.state.colorsChecked = true; // Mark colors as checked
}
- if (savedData.imageData) {
+ if (savedData.imageData && Array.isArray(savedData.imageData.pixels)) {
window.state.imageData = {
width: savedData.imageData.width,
height: savedData.imageData.height,
@@ -836,6 +891,52 @@ class WPlaceUtilsManager {
} catch (e) {
console.warn('Could not rebuild processor from saved image data:', e);
}
+ } else if (savedData.imageData && savedData.imageData.pixelsRef) {
+ // Asynchronously load large pixel data from IndexedDB using the stored reference
+ console.log('š Loading large image pixels from IndexedDB via ref:', savedData.imageData.pixelsRef);
+ window.state.imageData = {
+ width: savedData.imageData.width,
+ height: savedData.imageData.height,
+ totalPixels: savedData.imageData.totalPixels,
+ pixels: null,
+ };
+ window.state.imageLoaded = false;
+
+ this.loadPixelsFromIndexedDB(savedData.imageData.pixelsRef)
+ .then((payload) => {
+ if (!payload || !payload.pixels) {
+ console.warn('ā ļø No pixel payload found in IndexedDB for ref:', savedData.imageData.pixelsRef);
+ this.showAlert?.('ā ļø Saved image pixels not found in local database. Please reload the image file.', 'warning');
+ return;
+ }
+ try {
+ window.state.imageData.pixels = new Uint8ClampedArray(payload.pixels);
+ window.state.imageLoaded = true;
+
+ // Rebuild processor and notify modules/UI
+ this._syncModulesAfterStateRestore();
+ this.showAlert?.('ā
Large image restored from local database.', 'success');
+
+ // Attempt overlay restoration if available
+ if (typeof this.restoreOverlayFromData === 'function') {
+ this.restoreOverlayFromData().catch(() => {});
+ }
+ } catch (err) {
+ console.warn('Failed to apply pixels from IndexedDB:', err);
+ }
+ })
+ .catch((err) => {
+ console.warn('Failed to load pixels from IndexedDB:', err);
+ });
+ } else if (savedData.imageData && savedData.imageData.pixelsStripped) {
+ console.warn('ā ļø Saved progress did not include raw pixel data due to quota limits. Please reload the image to resume.');
+ window.state.imageData = {
+ width: savedData.imageData.width,
+ height: savedData.imageData.height,
+ totalPixels: savedData.imageData.totalPixels,
+ pixels: null,
+ };
+ window.state.imageLoaded = false;
}
// Prefer packed form if available; fallback to legacy paintedMap array for backward compatibility
@@ -1255,4 +1356,69 @@ if (!window.globalUtilsManager) {
console.error('ā Failed to reinitialize WPlaceUtilsManager:', error);
}
}, 100);
-}
\ No newline at end of file
+}
+
+// === IndexedDB helpers for large pixel storage ===
+WPlaceUtilsManager.prototype._openPixelDB = function() {
+ return new Promise((resolve, reject) => {
+ try {
+ const request = indexedDB.open('wplace-bot-db', 1);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains('pixels')) {
+ const store = db.createObjectStore('pixels', { keyPath: 'ref' });
+ try { store.createIndex('createdAt', 'createdAt', { unique: false }); } catch {}
+ }
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ } catch (e) {
+ reject(e);
+ }
+ });
+};
+
+WPlaceUtilsManager.prototype.generatePixelsRef = function(width, height) {
+ const rand = Math.random().toString(36).slice(2, 8);
+ return `img_${width}x${height}_${Date.now().toString(36)}_${rand}`;
+};
+
+WPlaceUtilsManager.prototype.storePixelsInIndexedDB = async function(ref, payload) {
+ try {
+ const db = await this._openPixelDB();
+ return await new Promise((resolve, reject) => {
+ const tx = db.transaction('pixels', 'readwrite');
+ const store = tx.objectStore('pixels');
+ const data = {
+ ref,
+ width: payload.width,
+ height: payload.height,
+ totalPixels: payload.totalPixels,
+ pixels: payload.pixels,
+ createdAt: Date.now(),
+ };
+ const req = store.put(data);
+ req.onsuccess = () => resolve(true);
+ req.onerror = () => reject(req.error);
+ });
+ } catch (e) {
+ console.warn('IDB storePixels failed:', e);
+ return false;
+ }
+};
+
+WPlaceUtilsManager.prototype.loadPixelsFromIndexedDB = async function(ref) {
+ try {
+ const db = await this._openPixelDB();
+ return await new Promise((resolve, reject) => {
+ const tx = db.transaction('pixels', 'readonly');
+ const store = tx.objectStore('pixels');
+ const req = store.get(ref);
+ req.onsuccess = () => resolve(req.result || null);
+ req.onerror = () => reject(req.error);
+ });
+ } catch (e) {
+ console.warn('IDB loadPixels failed:', e);
+ return null;
+ }
+};