Files
WPlace-AutoBOT/Extension/scripts/Auto-Image.js
T
2025-10-09 16:01:48 +03:00

10850 lines
430 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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 ? `<i class="${icon}"></i>` : ''}<span>${text}</span>`;
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 = payload.pixels instanceof Uint8Array || payload.pixels instanceof Uint8ClampedArray
? new Uint8ClampedArray(payload.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 = `<div class="wplace-colors-placeholder">${Utils.t(
'uploadImageFirst'
)}</div>`;
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 = `
<div class="wplace-header">
<div class="wplace-header-title">
<i class="fas fa-image"></i>
<span>${Utils.t('title')}</span>
</div>
<div class="wplace-header-controls">
<button id="settingsBtn" class="wplace-header-btn" title="${Utils.t('settings')}">
<i class="fas fa-cog"></i>
</button>
<button id="statsBtn" class="wplace-header-btn" title="${Utils.t('showStats')}">
<i class="fas fa-chart-bar"></i>
</button>
<button id="compactBtn" class="wplace-header-btn" title="${Utils.t('compactMode')}">
<i class="fas fa-compress"></i>
</button>
<button id="minimizeBtn" class="wplace-header-btn" title="${Utils.t('minimize')}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="wplace-content">
<!-- Status Section - Always visible -->
<div class="wplace-status-section">
<div id="statusText" class="wplace-status status-default">
${Utils.t('initMessage')}
</div>
<div class="wplace-progress">
<div id="progressBar" class="wplace-progress-bar" style="width: 0%"></div>
</div>
</div>
<!-- Image Section -->
<div class="wplace-section">
<div class="wplace-section-title">🖼️ Image Management</div>
<div class="wplace-controls">
<div class="wplace-row">
<button id="uploadBtn" class="wplace-btn wplace-btn-upload" disabled title="${Utils.t(
'waitingSetupComplete'
)}">
<i class="fas fa-upload"></i>
<span>${Utils.t('uploadImage')}</span>
</button>
<button id="loadExtractedBtn" class="wplace-btn wplace-btn-secondary" disabled title="Load artwork extracted from Art-Extractor">
<i class="fas fa-file-import"></i>
<span>Load Extracted</span>
</button>
</div>
<div class="wplace-row">
<button id="resizeBtn" class="wplace-btn wplace-btn-primary" disabled>
<i class="fas fa-expand"></i>
<span>${Utils.t('resizeImage')}</span>
</button>
<button id="moveArtworkBtn" class="wplace-btn wplace-btn-primary" disabled>
<i class="fas fa-arrows-alt"></i>
<span>Move Artwork</span>
</button>
</div>
<div class="wplace-row single">
<button id="selectPosBtn" class="wplace-btn wplace-btn-select" disabled>
<i class="fas fa-crosshairs"></i>
<span>${Utils.t('selectPosition')}</span>
</button>
</div>
</div>
</div>
<!-- Control Section -->
<div class="wplace-section">
<div class="wplace-section-title">🎮 Painting Control</div>
<div class="wplace-controls">
<div class="wplace-row">
<button id="startBtn" class="wplace-btn wplace-btn-start" disabled>
<i class="fas fa-play"></i>
<span>${Utils.t('startPainting')}</span>
</button>
<button id="stopBtn" class="wplace-btn wplace-btn-stop" disabled>
<i class="fas fa-stop"></i>
<span>${Utils.t('stopPainting')}</span>
</button>
</div>
<div class="wplace-row single">
<button id="toggleOverlayBtn" class="wplace-btn wplace-btn-overlay" disabled>
<i class="fas fa-eye"></i>
<span>${Utils.t('toggleOverlay')}</span>
</button>
</div>
</div>
</div>
<!-- Cooldown Section -->
<div class="wplace-section">
<div class="wplace-section-title">⏱️ ${Utils.t('cooldownSettings')}</div>
<div class="wplace-cooldown-control">
<label id="cooldownLabel">${Utils.t('waitCharges')}:</label>
<div class="wplace-dual-control-compact">
<div class="wplace-slider-container-compact">
<input type="range" id="cooldownSlider" class="wplace-overlay-opacity-slider" min="1" max="1" value="${state.cooldownChargeThreshold}">
</div>
<div class="wplace-input-group-compact">
<button id="cooldownDecrease" class="wplace-input-btn-compact" type="button">-</button>
<input type="number" id="cooldownInput" class="wplace-number-input-compact" min="1" max="999" value="${state.cooldownChargeThreshold}">
<button id="cooldownIncrease" class="wplace-input-btn-compact" type="button">+</button>
<span id="cooldownValue" class="wplace-input-label-compact">${Utils.t('charges')}</span>
<button id="skipCooldownBtn" class="wplace-btn wplace-btn-warning" style="margin-left: 8px; font-size: 11px; padding: 4px 8px;" disabled title="Skip current cooldown (only available during cooldown)">
<i class="fas fa-fast-forward"></i> Skip
</button>
</div>
</div>
</div>
</div>
<!-- Data Section -->
<div class="wplace-section">
<div class="wplace-section-title">💾 Data Management</div>
<div class="wplace-controls">
<div class="wplace-row">
<button id="saveBtn" class="wplace-btn wplace-btn-primary" disabled>
<i class="fas fa-save"></i>
<span>${Utils.t('saveData')}</span>
</button>
<button id="loadBtn" class="wplace-btn wplace-btn-primary" disabled title="${Utils.t(
'waitingTokenGenerator'
)}">
<i class="fas fa-folder-open"></i>
<span>${Utils.t('loadData')}</span>
</button>
</div>
<div class="wplace-row">
<button id="saveToFileBtn" class="wplace-btn wplace-btn-file" disabled>
<i class="fas fa-download"></i>
<span>${Utils.t('saveToFile')}</span>
</button>
<button id="loadFromFileBtn" class="wplace-btn wplace-btn-file" disabled title="${Utils.t(
'waitingTokenGenerator'
)}">
<i class="fas fa-upload"></i>
<span>${Utils.t('loadFromFile')}</span>
</button>
</div>
</div>
</div>
</div>
`;
const statsContainer = document.createElement('div');
statsContainer.id = 'wplace-stats-container';
statsContainer.style.display = 'none';
statsContainer.innerHTML = `
<div class="wplace-header">
<div class="wplace-header-title">
<i class="fas fa-chart-bar"></i>
<span>${Utils.t('paintingStats')}</span>
</div>
<div class="wplace-header-controls">
<button id="refreshChargesBtn" class="wplace-header-btn" title="${Utils.t('refreshCharges')}">
<i class="fas fa-sync"></i>
</button>
<button id="closeStatsBtn" class="wplace-header-btn" title="${Utils.t('closeStats')}">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="wplace-content">
<div class="wplace-stats">
<div id="statsArea">
<div class="wplace-stat-item">
<div class="wplace-stat-label"><i class="fas fa-info-circle"></i> ${Utils.t('initMessage')}</div>
</div>
</div>
</div>
<div class="wplace-section" id="account-swapper-section">
<div class="wplace-section-title" style="justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 6px;">
<i class="fas fa-sync-alt"></i>
<span>Account Swapper</span>
</div>
<label class="wplace-switch">
<input type="checkbox" id="autoSwapToggle">
<span class="wplace-slider-round"></span>
</label>
</div>
</div>
<div class="wplace-section" id="autobuy-section">
<div class="wplace-section-title" style="flex-direction: column; align-items: flex-start; gap: 8px;">
<div style="display: flex; align-items: center; gap: 6px; width: 100%;">
<i class="fas fa-shopping-cart"></i>
<span>Auto Buy Charges</span>
</div>
<div class="pill-container">
<div class="pill-highlight"></div>
<button class="pill-btn active" data-mode="none">Off</button>
<button class="pill-btn" data-mode="max_charges">Max</button>
<button class="pill-btn" data-mode="paint_charges">Paint</button>
</div>
</div>
</div>
<div class="wplace-section" id="all-accounts-section">
<div class="wplace-section-title">
<i class="fas fa-users"></i>
<span>All Accounts</span>
<button id="refreshAllAccountsBtn" class="wplace-header-btn" title="Refresh all accounts">
<i class="fas fa-users-cog"></i>
</button>
</div>
<div id="accountsListArea" class="accounts-list-container">
<div class="wplace-stat-item" style="opacity: 0.5;">Click the <i class="fas fa-users-cog"></i> icon to load accounts.</div>
</div>
</div>
</div>
`;
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 = `
<div class="wplace-settings-header">
<div class="wplace-settings-title-wrapper">
<h3 class="wplace-settings-title">
<i class="fas fa-cog wplace-settings-icon"></i>
${Utils.t('settings')}
</h3>
<button id="closeSettingsBtn" class="wplace-settings-close-btn">✕</button>
</div>
</div>
<div class="wplace-settings-content">
<!-- Token Source Selection -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-key wplace-icon-key"></i>
Token Source
</label>
<div class="wplace-settings-section-wrapper">
<select id="tokenSourceSelect" class="wplace-settings-select">
<option value="generator" ${state.tokenSource === 'generator' ? 'selected' : ''
} class="wplace-settings-option">🤖 Automatic Token Generator (Recommended)</option>
<option value="hybrid" ${state.tokenSource === 'hybrid' ? 'selected' : ''
} class="wplace-settings-option">🔄 Generator + Auto Fallback</option>
<option value="manual" ${state.tokenSource === 'manual' ? 'selected' : ''
} class="wplace-settings-option">🎯 Manual Pixel Placement</option>
</select>
<p class="wplace-settings-description">
Generator mode creates tokens automatically. Hybrid mode falls back to manual when generator fails. Manual mode only uses pixel placement.
</p>
</div>
</div>
<!-- Automation Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-robot wplace-icon-robot"></i>
${Utils.t('automation')}
</label>
<!-- Token generator is always enabled - settings moved to Token Source above -->
</div>
<!-- Overlay Settings Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label" style="color: ${theme.text || 'white'};">
<i class="fas fa-eye wplace-icon-eye" style="color: ${theme.highlight || '#48dbfb'
};"></i>
Overlay Settings
</label>
<div class="wplace-settings-section-wrapper wplace-overlay-wrapper" style="
background: ${theme.accent ? `${theme.accent}20` : 'rgba(255,255,255,0.1)'};
border-radius: ${theme.borderRadius || '12px'};
padding: 18px;
border: 1px solid ${theme.accent || 'rgba(255,255,255,0.1)'};
${theme.animations?.glow
? `
box-shadow: 0 0 15px ${theme.accent || 'rgba(255,255,255,0.1)'}33;
`
: ''
}
">
<!-- Opacity Slider -->
<div class="wplace-overlay-opacity-control">
<div class="wplace-overlay-opacity-header">
<span class="wplace-overlay-opacity-label" style="color: ${theme.text || 'white'
};">Overlay Opacity</span>
<div id="overlayOpacityValue" class="wplace-overlay-opacity-value" style="
background: ${theme.secondary || 'rgba(0,0,0,0.2)'};
color: ${theme.text || 'white'};
padding: 4px 8px;
border-radius: ${theme.borderRadius === '0' ? '0' : '6px'};
font-size: 12px;
border: 1px solid ${theme.accent || 'transparent'};
">${Math.round(state.overlayOpacity * 100)}%</div>
</div>
<input type="range" id="overlayOpacitySlider" min="0.1" max="1" step="0.05" value="${state.overlayOpacity}" class="wplace-overlay-opacity-slider" style="
background: linear-gradient(to right, ${theme.highlight || '#48dbfb'
} 0%, ${theme.purple || theme.neon || '#d3a4ff'} 100%);
border-radius: ${theme.borderRadius === '0' ? '0' : '4px'};
">
</div>
<!-- Blue Marble Toggle -->
<label for="enableBlueMarbleToggle" class="wplace-settings-toggle">
<div>
<span class="wplace-settings-toggle-title" style="color: ${theme.text || 'white'
};">Blue Marble Effect</span>
<p class="wplace-settings-toggle-description" style="color: ${theme.text ? `${theme.text}BB` : 'rgba(255,255,255,0.7)'
};">Renders a dithered "shredded" overlay.</p>
</div>
<input type="checkbox" id="enableBlueMarbleToggle" ${state.blueMarbleEnabled ? 'checked' : ''
} class="wplace-settings-checkbox" style="
accent-color: ${theme.highlight || '#48dbfb'};
"/>
</label>
</div>
</div>
<!-- Paint Options Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-paint-brush wplace-icon-paint"></i>
${Utils.t('paintOptions')}
</label>
<!-- Pixel Filter Toggles -->
<div id="pixelFilterControls" class="wplace-settings-section-wrapper wplace-pixel-filter-controls">
<!-- Paint White Pixels -->
<label class="wplace-settings-toggle">
<div>
<span class="wplace-settings-toggle-title" style="color: ${theme.text || 'white'};">
${Utils.t('paintWhitePixels')}
</span>
<p class="wplace-settings-toggle-description" style="color: ${theme.text ? `${theme.text}BB` : 'rgba(255,255,255,0.7)'
};">
${Utils.t('paintWhitePixelsDescription')}
</p>
</div>
<input type="checkbox" id="settingsPaintWhiteToggle" ${state.paintWhitePixels ? 'checked' : ''}
class="wplace-settings-checkbox"
style="accent-color: ${theme.highlight || '#48dbfb'};"/>
</label>
<!-- Paint Transparent Pixels -->
<label class="wplace-settings-toggle">
<div>
<span class="wplace-settings-toggle-title" style="color: ${theme.text || 'white'};">
${Utils.t('paintTransparentPixels')}
</span>
<p class="wplace-settings-toggle-description" style="color: ${theme.text ? `${theme.text}BB` : 'rgba(255,255,255,0.7)'
};">
${Utils.t('paintTransparentPixelsDescription')}
</p>
</div>
<input type="checkbox" id="settingsPaintTransparentToggle" ${state.paintTransparentPixels ? 'checked' : ''}
class="wplace-settings-checkbox"
style="accent-color: ${theme.highlight || '#48dbfb'};"/>
</label>
<label class="wplace-settings-toggle">
<div>
<span class="wplace-settings-toggle-title" style="color: ${theme.text || 'white'
};">${Utils.t('paintUnavailablePixels')}</span>
<p class="wplace-settings-toggle-description" style="color: ${theme.text ? `${theme.text}BB` : 'rgba(255,255,255,0.7)'
};">${Utils.t('paintUnavailablePixelsDescription')}</p>
</div>
<input type="checkbox" id="paintUnavailablePixelsToggle" ${state.paintUnavailablePixels ? 'checked' : ''
} class="wplace-settings-checkbox" style="
accent-color: ${theme.highlight || '#48dbfb'};
"/>
</label>
</div>
</div>
<!-- Speed Control Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-tachometer-alt wplace-icon-speed"></i>
${Utils.t('paintingSpeed')}
</label>
<!-- Painting Order Selection -->
<div class="wplace-mode-selection">
<label class="wplace-mode-label">
<i class="fas fa-palette wplace-icon-palette"></i>
${Utils.t('paintingOrder')}
</label>
<select id="paintingOrderSelect" class="wplace-settings-select">
<option value="sequential" class="wplace-settings-option">📐 Normal</option>
<option value="color-by-color" class="wplace-settings-option">🎨 Color By Color</option>
</select>
</div>
<!-- Batch Mode Selection -->
<div class="wplace-mode-selection">
<label class="wplace-mode-label">
<i class="fas fa-dice wplace-icon-dice"></i>
Batch Mode
</label>
<select id="batchModeSelect" class="wplace-settings-select">
<option value="normal" class="wplace-settings-option">📦 Normal (Fixed Size)</option>
<option value="random" class="wplace-settings-option">🎲 Random (Range)</option>
</select>
</div>
<!-- Normal Mode: Fixed Size Controls -->
<div id="normalBatchControls" class="wplace-batch-controls wplace-normal-batch-controls">
<div class="wplace-batch-size-header">
<span class="wplace-batch-size-label">${Utils.t('batchSize')}</span>
</div>
<div class="wplace-dual-control-compact">
<div class="wplace-speed-slider-container-compact">
<input type="range" id="speedSlider" min="${CONFIG.PAINTING_SPEED.MIN}" max="${CONFIG.PAINTING_SPEED.MAX}" value="${state.paintingSpeed}" class="wplace-overlay-opacity-slider">
</div>
<div class="wplace-speed-input-container-compact">
<div class="wplace-input-group-compact">
<button id="speedDecrease" class="wplace-input-btn-compact" type="button">-</button>
<input type="number" id="speedInput" class="wplace-number-input-compact" min="${CONFIG.PAINTING_SPEED.MIN}" max="${CONFIG.PAINTING_SPEED.MAX}" value="${state.paintingSpeed}">
<button id="speedIncrease" class="wplace-input-btn-compact" type="button">+</button>
<span id="speedValue" class="wplace-input-label-compact">pixels</span>
</div>
</div>
</div>
<div class="wplace-speed-labels">
<span class="wplace-speed-min"><i class="fas fa-turtle"></i> ${CONFIG.PAINTING_SPEED.MIN}</span>
<span class="wplace-speed-max"><i class="fas fa-rabbit"></i> ${CONFIG.PAINTING_SPEED.MAX}</span>
</div>
</div>
<!-- Random Mode: Range Controls -->
<div id="randomBatchControls" class="wplace-batch-controls wplace-random-batch-controls">
<div class="wplace-random-batch-grid">
<div>
<label class="wplace-random-batch-label">
<i class="fas fa-arrow-down wplace-icon-min"></i>
Minimum Batch Size
</label>
<input type="number" id="randomBatchMin" min="1" max="1000" value="${CONFIG.RANDOM_BATCH_RANGE.MIN}" class="wplace-settings-number-input">
</div>
<div>
<label class="wplace-random-batch-label">
<i class="fas fa-arrow-up wplace-icon-max"></i>
Maximum Batch Size
</label>
<input type="number" id="randomBatchMax" min="1" max="1000" value="${CONFIG.RANDOM_BATCH_RANGE.MAX}" class="wplace-settings-number-input">
</div>
</div>
<p class="wplace-random-batch-description">
🎲 Random batch size between min and max values
</p>
</div>
<!-- Speed Control Toggle -->
<label class="wplace-speed-control-toggle">
<input type="checkbox" id="enableSpeedToggle" ${CONFIG.PAINTING_SPEED_ENABLED ? 'checked' : ''
} class="wplace-speed-checkbox"/>
<span>${Utils.t('enablePaintingSpeedLimit')}</span>
<div class="wplace-speed-toggle-description">When disabled, bot uses all available charges immediately for maximum speed</div>
</label>
</div>
<!-- Coordinate Generation Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-route wplace-icon-route"></i>
Coordinate Generation
</label>
<!-- Mode Selection -->
<div class="wplace-mode-selection">
<label class="wplace-mode-label">
<i class="fas fa-th wplace-icon-table"></i>
Generation Mode
</label>
<select id="coordinateModeSelect" class="wplace-settings-select">
<option value="rows" class="wplace-settings-option">📏 Rows (Horizontal Lines)</option>
<option value="columns" class="wplace-settings-option">📐 Columns (Vertical Lines)</option>
<option value="circle-out" class="wplace-settings-option">⭕ Circle Out (Center → Edges)</option>
<option value="circle-in" class="wplace-settings-option">⭕ Circle In (Edges → Center)</option>
<option value="blocks" class="wplace-settings-option">🟫 Blocks (Ordered)</option>
<option value="shuffle-blocks" class="wplace-settings-option">🎲 Shuffle Blocks (Random)</option>
</select>
</div>
<!-- Direction Selection (only for rows/columns) -->
<div id="directionControls" class="wplace-mode-selection">
<label class="wplace-mode-label">
<i class="fas fa-compass wplace-icon-compass"></i>
Starting Direction
</label>
<select id="coordinateDirectionSelect" class="wplace-settings-select">
<option value="top-left" class="wplace-settings-option">↖️ Top-Left</option>
<option value="top-right" class="wplace-settings-option">↗️ Top-Right</option>
<option value="bottom-left" class="wplace-settings-option">↙️ Bottom-Left</option>
<option value="bottom-right" class="wplace-settings-option">↘️ Bottom-Right</option>
</select>
</div>
<!-- Snake Pattern Toggle (only for rows/columns) -->
<div id="snakeControls" class="wplace-snake-pattern-controls wplace-settings-section-wrapper">
<label class="wplace-settings-toggle">
<div>
<span class="wplace-settings-toggle-title" style="color: ${theme.text || 'white'
};">Snake Pattern</span>
<p class="wplace-settings-toggle-description" style="color: ${theme.text ? `${theme.text}BB` : 'rgba(255,255,255,0.7)'
};">Alternate direction for each row/column (zigzag pattern)</p>
</div>
<input type="checkbox" id="coordinateSnakeToggle" ${state.coordinateSnake ? 'checked' : ''
} class="wplace-settings-checkbox" style="
accent-color: ${theme.highlight || '#48dbfb'};
"/>
</label>
</div>
<!-- Block Size Controls (only for blocks/shuffle-blocks) -->
<div id="blockControls" class="wplace-block-size-controls wplace-settings-section-wrapper wplace-shuffle-block-size-controls">
<div class="wplace-block-size-grid">
<div>
<label class="wplace-block-size-label">
<i class="fas fa-arrows-alt-h wplace-icon-width"></i>
Block Width
</label>
<input type="number" id="blockWidthInput" min="1" max="50" value="6" class="wplace-settings-number-input">
</div>
<div>
<label style="display: block; color: rgba(255,255,255,0.8); font-size: 12px; margin-bottom: 8px;">
<i class="fas fa-arrows-alt-v wplace-icon-height"></i>
Block Height
</label>
<input type="number" id="blockHeightInput" min="1" max="50" value="2" class="wplace-settings-number-input">
</div>
</div>
<p class="wplace-block-size-description">
🧱 Block dimensions for block-based generation modes
</p>
</div>
</div>
<!-- Notifications Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-bell wplace-icon-bell"></i>
Desktop Notifications
</label>
<div class="wplace-settings-section-wrapper wplace-notifications-wrapper">
<label class="wplace-notification-toggle">
<span>${Utils.t('enableNotifications')}</span>
<input type="checkbox" id="notifEnabledToggle" ${state.notificationsEnabled ? 'checked' : ''
} class="wplace-notification-checkbox" />
</label>
<label class="wplace-notification-toggle">
<span>${Utils.t('notifyOnChargesThreshold')}</span>
<input type="checkbox" id="notifOnChargesToggle" ${state.notifyOnChargesReached ? 'checked' : ''
} class="wplace-notification-checkbox" />
</label>
<label class="wplace-notification-toggle">
<span>${Utils.t('onlyWhenNotFocused')}</span>
<input type="checkbox" id="notifOnlyUnfocusedToggle" ${state.notifyOnlyWhenUnfocused ? 'checked' : ''
} class="wplace-notification-checkbox" />
</label>
<div class="wplace-notification-interval">
<span>${Utils.t('repeatEvery')}</span>
<input type="number" id="notifIntervalInput" min="1" max="60" value="${state.notificationIntervalMinutes}" class="wplace-notification-interval-input" />
<span>${Utils.t('minutesPl')}</span>
</div>
<div class="wplace-notification-buttons">
<button id="notifRequestPermBtn" class="wplace-btn wplace-btn-secondary wplace-notification-perm-btn"><i class="fas fa-unlock"></i><span>${Utils.t(
'grantPermission'
)}</span></button>
<button id="notifTestBtn" class="wplace-btn wplace-notification-test-btn"><i class="fas fa-bell"></i><span>${Utils.t(
'test'
)}</span></button>
</div>
</div>
</div>
<!-- Theme Selection Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-palette wplace-icon-palette"></i>
${Utils.t('themeSettings')}
</label>
<div class="wplace-settings-section-wrapper">
<select id="themeSelect" class="wplace-settings-select">
${Object.keys(CONFIG.THEMES)
.map(
(themeName) =>
`<option value="${themeName}" ${CONFIG.currentTheme === themeName ? 'selected' : ''
} class="wplace-settings-option">${themeName}</option>`
)
.join('')}
</select>
</div>
</div>
<!-- Language Selection Section -->
<div class="wplace-settings-section">
<label class="wplace-settings-section-label">
<i class="fas fa-globe wplace-icon-globe"></i>
${Utils.t('language')}
</label>
<div class="wplace-settings-section-wrapper">
<select id="languageSelect" class="wplace-settings-select">
<option value="vi" ${state.language === 'vi' ? 'selected' : ''} class="wplace-settings-option">🇻🇳 Tiếng Việt</option>
<option value="id" ${state.language === 'id' ? 'selected' : ''} class="wplace-settings-option">🇮🇩 Bahasa Indonesia</option>
<option value="ru" ${state.language === 'ru' ? 'selected' : ''} class="wplace-settings-option">🇷🇺 Русский</option>
<option value="uk" ${state.language === 'uk' ? 'selected' : ''} class="wplace-settings-option">🇺🇦 Українська</option>
<option value="en" ${state.language === 'en' ? 'selected' : ''} class="wplace-settings-option">🇺🇸 English</option>
<option value="es" ${state.language === 'es' ? 'selected' : ''} class="wplace-settings-option">🇪🇸 Español</option>
<option value="pt" ${state.language === 'pt' ? 'selected' : ''} class="wplace-settings-option">🇧🇷 Português</option>
<option value="fr" ${state.language === 'fr' ? 'selected' : ''} class="wplace-settings-option">🇫🇷 Français</option>
<option value="tr" ${state.language === 'tr' ? 'selected' : ''} class="wplace-settings-option">🇹🇷 Türkçe</option>
<option value="zh-CN" ${state.language === 'zh-CN' ? 'selected' : ''} class="wplace-settings-option">🇨🇳 简体中文</option>
<option value="zh-TW" ${state.language === 'zh-TW' ? 'selected' : ''} class="wplace-settings-option">🇹🇼 繁體中文</option>
<option value="ja" ${state.language === 'ja' ? 'selected' : ''} class="wplace-settings-option">🇯🇵 日本語</option>
<option value="ko" ${state.language === 'ko' ? 'selected' : ''} class="wplace-settings-option">🇰🇷 한국어</option>
</select>
</div>
</div>
</div>
<div class="wplace-settings-footer">
<button id="applySettingsBtn" class="wplace-settings-apply-btn">
<i class="fas fa-check"></i> ${Utils.t('applySettings')}
</button>
</div>
<style>
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes settings-slide-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes settings-fade-out {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
#speedSlider::-webkit-slider-thumb, #cooldownSlider::-webkit-slider-thumb, #overlayOpacitySlider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
box-shadow: 0 3px 6px rgba(0,0,0,0.3), 0 0 0 2px #4facfe;
cursor: pointer;
transition: all 0.2s ease;
}
#speedSlider::-webkit-slider-thumb:hover, #cooldownSlider::-webkit-slider-thumb:hover, #overlayOpacitySlider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 4px 8px rgba(0,0,0,0.4), 0 0 0 3px #4facfe;
}
#speedSlider::-moz-range-thumb, #cooldownSlider::-moz-range-thumb, #overlayOpacitySlider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
box-shadow: 0 3px 6px rgba(0,0,0,0.3), 0 0 0 2px #4facfe;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
#themeSelect:hover, #languageSelect:hover {
border-color: rgba(255,255,255,0.4);
background: rgba(255,255,255,0.2);
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
}
#themeSelect:focus, #languageSelect:focus {
border-color: #4facfe;
box-shadow: 0 0 0 3px rgba(79, 172, 254, 0.3);
}
#themeSelect option, #languageSelect option {
background: #2d3748;
color: white;
padding: 10px;
border-radius: 6px;
}
#themeSelect option:hover, #languageSelect option:hover {
background: #4a5568;
}
.wplace-dragging {
opacity: 0.9;
box-shadow: 0 30px 60px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.2);
transition: none;
}
.wplace-settings-header:hover {
background: rgba(255,255,255,0.15) !important;
}
.wplace-settings-header:active {
background: rgba(255,255,255,0.2) !important;
}
/* Custom Scrollbar for Content Area */
.wplace-content::-webkit-scrollbar {
width: 6px;
}
.wplace-content::-webkit-scrollbar-track {
background: rgba(255,255,255,0.1);
border-radius: 3px;
}
.wplace-content::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
border-radius: 3px;
}
.wplace-content::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.5);
}
</style>
`;
const resizeContainer = document.createElement('div');
resizeContainer.className = 'resize-container';
resizeContainer.innerHTML = `
<h3 class="resize-dialog-title" style="color: ${theme.text}">${Utils.t('resizeImage')}</h3>
<div class="resize-controls">
<label class="resize-control-label">
Width: <span id="widthValue">0</span>px
<input type="range" id="widthSlider" class="resize-slider" min="10" max="500" value="100">
</label>
<label class="resize-control-label">
Height: <span id="heightValue">0</span>px
<input type="range" id="heightSlider" class="resize-slider" min="10" max="500" value="100">
</label>
<!-- Edit button moved here after height slider -->
<div class="edit-button-container" style="margin: 15px 0; text-align: center;">
<button id="editImageBtn" class="wplace-btn wplace-btn-select">
<i class="fas fa-paint-brush"></i>
<span>Edit</span>
</button>
</div>
<label class="resize-checkbox-label">
<input type="checkbox" id="keepAspect" checked>
${Utils.t('keepAspectRatio')}
</label>
<label class="resize-checkbox-label">
<input type="checkbox" id="paintWhiteToggle" checked>
${Utils.t('paintWhitePixels')}
</label>
<label class="resize-checkbox-label">
<input type="checkbox" id="paintTransparentToggle" checked>
${Utils.t('paintTransparentPixels')}
</label>
<div class="resize-zoom-controls">
<button id="zoomOutBtn" class="wplace-btn resize-zoom-btn" title="${Utils.t(
'zoomOut'
)}"><i class="fas fa-search-minus"></i></button>
<input type="range" id="zoomSlider" class="resize-slider resize-zoom-slider" min="0.1" max="20" value="1" step="0.05">
<button id="zoomInBtn" class="wplace-btn resize-zoom-btn" title="${Utils.t(
'zoomIn'
)}"><i class="fas fa-search-plus"></i></button>
<button id="zoomFitBtn" class="wplace-btn resize-zoom-btn" title="${Utils.t(
'fitToView'
)}">${Utils.t('fit')}</button>
<button id="zoomActualBtn" class="wplace-btn resize-zoom-btn" title="${Utils.t(
'actualSize'
)}">${Utils.t('hundred')}</button>
<button id="panModeBtn" class="wplace-btn resize-zoom-btn" title="${Utils.t('panMode')}">
<i class="fas fa-hand-paper"></i>
</button>
<span id="zoomValue" class="resize-zoom-value">100%</span>
<div id="cameraHelp" class="resize-camera-help">
Drag to pan • Pinch to zoom • Doubletap to zoom
</div>
</div>
</div>
<div class="resize-preview-wrapper">
<div id="resizePanStage" class="resize-pan-stage">
<div id="resizeCanvasStack" class="resize-canvas-stack resize-canvas-positioned">
<canvas id="resizeCanvas" class="resize-base-canvas"></canvas>
<canvas id="maskCanvas" class="resize-mask-canvas"></canvas>
</div>
</div>
</div>
<div class="resize-tools">
<div class="resize-tools-container">
<div class="resize-brush-controls">
<div class="resize-brush-control">
<label class="resize-tool-label">Brush</label>
<div class="resize-tool-input-group">
<input id="maskBrushSize" type="range" min="1" max="255" step="1" value="1" class="resize-tool-slider">
<span id="maskBrushSizeValue" class="resize-tool-value">1</span>
</div>
</div>
<div class="resize-brush-control">
<label class="resize-tool-label">Row/col size</label>
<div class="resize-tool-input-group">
<input id="rowColSize" type="range" min="1" max="255" step="1" value="1" class="resize-tool-slider">
<span id="rowColSizeValue" class="resize-tool-value">1</span>
</div>
</div>
</div>
<div class="resize-mode-controls">
<label class="resize-tool-label">Mode</label>
<div class="mask-mode-group resize-mode-group">
<button id="maskModeIgnore" class="wplace-btn resize-mode-btn">Ignore</button>
<button id="maskModeUnignore" class="wplace-btn resize-mode-btn">Unignore</button>
<button id="maskModeToggle" class="wplace-btn wplace-btn-primary resize-mode-btn">Toggle</button>
</div>
</div>
<button id="clearIgnoredBtn" class="wplace-btn resize-clear-btn" title="Clear all ignored pixels">Clear</button>
<button id="invertMaskBtn" class="wplace-btn resize-invert-btn" title="Invert mask">Invert</button>
<span class="resize-shortcut-help">Shift = Row • Alt = Column</span>
</div>
</div>
<div class="wplace-section resize-color-palette-section" id="color-palette-section">
<div class="wplace-section-title">
<i class="fas fa-palette"></i>&nbsp;Color Palette
</div>
<div class="wplace-controls">
<div class="wplace-row single">
<label class="resize-color-toggle-label">
<input type="checkbox" id="showAllColorsToggle" class="resize-color-checkbox">
<span>${Utils.t('showAllColorsIncluding')}</span>
</label>
</div>
<div class="wplace-row" style="display: flex;">
<button id="selectAllBtn" class="wplace-btn" style="flex: 1;">Select All</button>
<button id="unselectAllBtn" class="wplace-btn" style="flex: 1;">Unselect All</button>
<button id="unselectPaidBtn" class="wplace-btn">Unselect Paid</button>
</div>
<div id="colors-container" class="wplace-color-grid"></div>
</div>
</div>
<div class="wplace-section resize-advanced-color-section" id="advanced-color-section">
<div class="wplace-section-title">
<i class="fas fa-flask"></i>&nbsp;Advanced Color Matching
</div>
<div class="resize-advanced-controls">
<label class="resize-advanced-label">
<span class="resize-advanced-label-text">Algorithm</span>
<select id="colorAlgorithmSelect" class="resize-advanced-select">
<option value="lab" ${state.colorMatchingAlgorithm === 'lab' ? 'selected' : ''
}>Perceptual (Lab)</option>
<option value="legacy" ${state.colorMatchingAlgorithm === 'legacy' ? 'selected' : ''
}>Legacy (RGB)</option>
<option value="hsv" ${state.colorMatchingAlgorithm === 'hsv' ? 'selected' : ''
}>HSV (Hue-Saturation-Value)</option>
<option value="hsl" ${state.colorMatchingAlgorithm === 'hsl' ? 'selected' : ''
}>HSL (Hue-Saturation-Lightness)</option>
<option value="xyz" ${state.colorMatchingAlgorithm === 'xyz' ? 'selected' : ''
}>XYZ (CIE Color Space)</option>
<option value="luv" ${state.colorMatchingAlgorithm === 'luv' ? 'selected' : ''
}>LUV (CIE L*u*v*)</option>
<option value="yuv" ${state.colorMatchingAlgorithm === 'yuv' ? 'selected' : ''
}>YUV (Luma-Chroma)</option>
<option value="oklab" ${state.colorMatchingAlgorithm === 'oklab' ? 'selected' : ''
}>Oklab (Perceptual Uniform)</option>
<option value="lch" ${state.colorMatchingAlgorithm === 'lch' ? 'selected' : ''
}>LCH (Lightness-Chroma-Hue)</option>
</select>
</label>
<label class="resize-advanced-toggle">
<div class="resize-advanced-toggle-content">
<span class="resize-advanced-label-text">Chroma Penalty</span>
<div class="resize-advanced-description">Preserve vivid colors (Lab only)</div>
</div>
<input type="checkbox" id="enableChromaPenaltyToggle" ${state.enableChromaPenalty ? 'checked' : ''
} class="resize-advanced-checkbox" />
</label>
<div class="resize-chroma-weight-control">
<div class="resize-chroma-weight-header">
<span>${Utils.t('chromaWeight')}</span>
<span id="chromaWeightValue" class="resize-chroma-weight-value">${state.chromaPenaltyWeight}</span>
</div>
<input type="range" id="chromaPenaltyWeightSlider" min="0" max="0.5" step="0.01" value="${state.chromaPenaltyWeight}" class="resize-chroma-weight-slider" />
</div>
<label class="resize-advanced-toggle">
<div class="resize-advanced-toggle-content">
<span class="resize-advanced-label-text">Enable Dithering</span>
<div class="resize-advanced-description">FloydSteinberg error diffusion in preview and applied output</div>
</div>
<input type="checkbox" id="enableDitheringToggle" ${state.ditheringEnabled ? 'checked' : ''
} class="resize-advanced-checkbox" />
</label>
<div class="resize-threshold-controls">
<label class="resize-threshold-label">
<span class="resize-advanced-label-text">Transparency</span>
<input type="number" id="transparencyThresholdInput" min="0" max="255" value="${state.customTransparencyThreshold}" class="resize-threshold-input" />
</label>
<label class="resize-threshold-label">
<span class="resize-advanced-label-text">White Thresh</span>
<input type="number" id="whiteThresholdInput" min="200" max="255" value="${state.customWhiteThreshold}" class="resize-threshold-input" />
</label>
</div>
<button id="resetAdvancedColorBtn" class="wplace-btn resize-reset-advanced-btn">Reset Advanced</button>
</div>
</div>
<div class="resize-buttons">
<button id="downloadPreviewBtn" class="wplace-btn wplace-btn-primary">
<i class="fas fa-download"></i>
<span>${Utils.t('downloadPreview')}</span>
</button>
<button id="confirmResize" class="wplace-btn wplace-btn-start">
<i class="fas fa-check"></i>
<span>${Utils.t('apply')}</span>
</button>
<button id="cancelResize" class="wplace-btn wplace-btn-stop">
<i class="fas fa-times"></i>
<span>${Utils.t('cancel')}</span>
</button>
</div>
`;
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 = '<i class="fas fa-chart-bar"></i>';
statsBtn.title = Utils.t('showStats');
} else {
statsContainer.style.display = 'block';
statsBtn.innerHTML = '<i class="fas fa-chart-line"></i>';
statsBtn.title = Utils.t('hideStats');
}
});
closeStatsBtn.addEventListener('click', () => {
statsContainer.style.display = 'none';
statsBtn.innerHTML = '<i class="fas fa-chart-bar"></i>';
statsBtn.title = Utils.t('showStats');
});
if (refreshChargesBtn) {
refreshChargesBtn.addEventListener('click', async () => {
refreshChargesBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
refreshChargesBtn.disabled = true;
try {
await updateStats();
await updateCurrentAccountInList();
} catch (error) {
console.error('Error refreshing charges:', error);
} finally {
refreshChargesBtn.innerHTML = '<i class="fas fa-sync"></i>';
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 = '<i class="fas fa-chart-line"></i>';
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 = '<i class="fas fa-expand"></i>';
compactBtn.title = Utils.t('expandMode');
} else {
compactBtn.innerHTML = '<i class="fas fa-compress"></i>';
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 = '<i class="fas fa-expand"></i>';
minimizeBtn.title = Utils.t('restore');
} else {
container.classList.remove('wplace-minimized');
content.classList.remove('wplace-hidden');
minimizeBtn.innerHTML = '<i class="fas fa-minus"></i>';
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 = '<i class="fas fa-expand"></i>';
minimizeBtn.title = Utils.t('restore');
}
} else {
container.classList.remove('wplace-minimized');
content.classList.remove('wplace-hidden');
if (minimizeBtn) {
minimizeBtn.innerHTML = '<i class="fas fa-minus"></i>';
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 = `<span style="color:#10b981;">FULL</span>`;
} else {
fullChargeEl.innerHTML = `
<span style="color:#f59e0b;">${timeText}</span>
`;
}
}
}
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 = `
<div class="wplace-stat-item">
<div class="wplace-stat-label"><i class="fas fa-image"></i> ${Utils.t('progress')}</div>
<div class="wplace-stat-value">${progress}%</div>
</div>
<div class="wplace-stat-item">
<div class="wplace-stat-label"><i class="fas fa-paint-brush"></i> ${Utils.t(
'pixels'
)}</div>
<div class="wplace-stat-value">${state.paintedPixels}/${state.totalPixels}</div>
</div>
`;
}
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 `<div class="wplace-stat-color-swatch" style="background-color: ${rgbString};" title="${Utils.t(
'colorTooltip',
{ id: color.id, rgb: color.rgb.join(', ') }
)}"></div>`;
})
.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 ? `
<div class="wplace-stat-item">
<div class="wplace-stat-label">
<i class="fas fa-coins"></i> Total All Accounts Charges
</div>
<div class="wplace-stat-value">
${totalAllCharges}/${totalMaxCharges}
</div>
</div>
` : ''}
<div class="wplace-stat-item">
<div class="wplace-stat-label">
<i class="fas fa-bolt"></i> ${Utils.t('charges')}
</div>
<div class="wplace-stat-value" id="wplace-stat-charges-value">
${state.displayCharges} / ${state.maxCharges}
</div>
</div>
<div class="wplace-stat-item">
<div class="wplace-stat-label">
<i class="fas fa-battery-half"></i> ${Utils.t('fullChargeIn')}
</div>
<div class="wplace-stat-value" id="wplace-stat-fullcharge-value">--:--:--</div>
</div>
${state.colorsChecked
? `
<div class="wplace-colors-section">
<div class="wplace-stat-label"><i class="fas fa-palette"></i> ${Utils.t(
'availableColors',
{ count: state.availableColors.length }
)}</div>
<div class="wplace-stat-colors-grid">
${colorSwatchesHTML}
</div>
</div>
`
: ''
}
`;
// 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 = `
<div class="wplace-header">
<div class="wplace-header-title">
<i class="fas fa-arrows-alt"></i>
Move Artwork
</div>
<div class="wplace-header-controls">
<button id="closeMovePanel" class="wplace-header-btn" title="Close">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="wplace-move-controls">
<div></div>
<button id="moveUp" class="wplace-move-btn" data-direction="up">▲</button>
<div></div>
<button id="moveLeft" class="wplace-move-btn" data-direction="left">◄</button>
<div class="wplace-move-center">1px</div>
<button id="moveRight" class="wplace-move-btn" data-direction="right">►</button>
<div></div>
<button id="moveDown" class="wplace-move-btn" data-direction="down">▼</button>
<div></div>
</div>
`;
// 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 = `
<div class="edit-container">
<div class="edit-header">
<div class="edit-header-left">
<h3>Manual Pixel Art Editor</h3>
<div class="edit-instructions">
<small>🖱️ Left click: Draw | Right click/Ctrl+drag: Pan | Mouse wheel: Zoom</small>
</div>
</div>
<div class="edit-nav-controls">
<div class="zoom-controls">
<button id="editZoomOut" class="zoom-btn" title="Zoom Out (Ctrl + -)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13H5v-2h14v2z"/>
</svg>
</button>
<select id="zoomSelect" class="zoom-select" title="Zoom Level">
<option value="0.1">10%</option>
<option value="0.25">25%</option>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1" selected>100%</option>
<option value="1.5">150%</option>
<option value="2">200%</option>
<option value="3">300%</option>
<option value="4">400%</option>
<option value="6">600%</option>
<option value="8">800%</option>
<option value="12">1200%</option>
<option value="16">1600%</option>
<option value="24">2400%</option>
<option value="32">3200%</option>
</select>
<button id="editZoomIn" class="zoom-btn" title="Zoom In (Ctrl + +)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</button>
<button id="zoomFit" class="zoom-btn" title="Fit to Window (Ctrl + 0)">📰</button>
<button id="zoom100" class="zoom-btn" title="Actual Size (Ctrl + 1)">1:1</button>
</div>
<div class="edit-controls">
<button id="editBackBtn" class="wplace-btn wplace-btn-secondary">← Back</button>
<button id="editApplyBtn" class="wplace-btn wplace-btn-confirm">Apply Changes</button>
</div>
</div>
</div>
<div class="edit-content">
<div class="edit-main-area">
<div class="edit-toolbar">
<div class="edit-tool-group">
<label>Tools:</label>
<button id="paintBrush" class="edit-tool active" data-tool="paint" title="Brush (B)">🖌</button>
<button id="eraseTool" class="edit-tool" data-tool="erase" title="Eraser (E)">🗑</button>
<button id="eyedropperTool" class="edit-tool" data-tool="eyedropper" title="Eyedropper (I)">💉</button>
<button id="fillTool" class="edit-tool" data-tool="fill" title="Fill (F)">🪣</button>
</div>
<div class="edit-tool-group">
<label>Brush Size:</label>
<input type="range" id="brushSize" min="1" max="20" value="1" class="edit-slider" title="Use [ ] keys">
<span id="brushSizeValue">1</span>
<button id="showGrid" class="edit-toggle" title="Toggle Grid (G)">⊞</button>
</div>
<div class="edit-tool-group">
<button id="undoBtn" class="wplace-btn wplace-btn-secondary" title="Undo (Ctrl+Z)">↶</button>
<button id="redoBtn" class="wplace-btn wplace-btn-secondary" title="Redo (Ctrl+Shift+Z)">↷</button>
<button id="resetViewBtn" class="wplace-btn wplace-btn-secondary" title="Reset View">🔄</button>
</div>
</div>
<div class="edit-canvas-area">
<div class="edit-canvas-container" id="editCanvasContainer">
<div class="edit-canvas-wrapper" id="editCanvasWrapper">
<canvas id="editCanvas" class="edit-canvas"></canvas>
</div>
<div class="minimap-container" id="minimapContainer">
<div class="minimap-header">Navigator</div>
<div class="minimap-content">
<canvas id="minimapCanvas" class="minimap-canvas"></canvas>
<div id="minimapViewport" class="minimap-viewport"></div>
</div>
</div>
</div>
</div>
</div>
<div class="edit-bottom-bar">
<div class="edit-info-section">
<span id="editStatusBar">Position: (0, 0) | Color: #000000 | Zoom: 100%</span>
</div>
<div class="edit-current-color">
<label>Current:</label>
<div id="currentColorDisplay" class="color-display"></div>
</div>
<div class="edit-available-colors">
<label>Available Colors (<span id="editColorCount">0</span>):</label>
<div id="editColorGrid" class="edit-color-grid">
<!-- Colors will be populated here -->
</div>
</div>
</div>
</div>
</div>
</div>
`;
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 (avoid spread on large arrays)
for (const block of blocks) {
coords = coords.concat(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 {
// 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 with very large arrays to prevent call stack overflow
pixelsToProcess = pixelsToProcess.concat(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 = '<i class="fas fa-spinner fa-spin"></i>';
refreshBtn.disabled = true;
}
const accountsListArea = document.getElementById('accountsListArea');
if (accountsListArea) {
accountsListArea.innerHTML = `<div class="wplace-stat-item" style="opacity: 0.5;">Loading accounts...</div>`;
}
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 = `<div class="wplace-stat-item" style="color: red;">Error loading accounts.</div>`;
}
} finally {
state.isFetchingAllAccounts = false;
if (refreshBtn) {
refreshBtn.innerHTML = '<i class="fas fa-users-cog"></i>';
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 = `
<span><i class="fas fa-bolt"></i> ${charges}/${max}</span>
<span><i class="fas fa-tint"></i> ${droplets}</span>
`;
details.appendChild(accountName);
details.appendChild(accountStats);
// Add status indicators
const status = document.createElement('div');
status.className = 'wplace-account-status';
if (isCurrentAccount) {
status.innerHTML = '<i class="fas fa-play-circle" style="color: #2ecc71;" title="Current Account"></i>';
} else if (isNextInSequence) {
status.innerHTML = '<i class="fas fa-arrow-right" style="color: #f39c12;" title="Next Account"></i>';
}
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) {
coords = coords.concat(block); // avoid spread on huge arrays
}
} 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
);
}
}