mirror of
https://github.com/tiennm99/WPlace-AutoBOT.git
synced 2026-05-30 00:20:32 +00:00
8695ee1d0c
(cherry picked from commit ca4e5244ba)
8570 lines
398 KiB
JavaScript
8570 lines
398 KiB
JavaScript
; (async () => {
|
||
// 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_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: true,
|
||
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: false,
|
||
},
|
||
// --- 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,
|
||
pixelBlink: 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,
|
||
pixelBlink: true,
|
||
},
|
||
},
|
||
},
|
||
currentTheme: "Classic Autobot",
|
||
autoSwap: true,
|
||
}
|
||
|
||
const getCurrentTheme = () => CONFIG.THEMES[CONFIG.currentTheme]
|
||
|
||
const switchTheme = (themeName) => {
|
||
if (CONFIG.THEMES[themeName]) {
|
||
CONFIG.currentTheme = themeName
|
||
saveThemePreference()
|
||
|
||
// Remove existing theme styles
|
||
const existingStyle = document.querySelector('style[data-wplace-theme="true"]')
|
||
if (existingStyle) {
|
||
existingStyle.remove()
|
||
}
|
||
|
||
// Recreate UI with new theme (cleanup is handled in createUI)
|
||
createUI()
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
const loadLanguagePreference = () => {
|
||
try {
|
||
const saved = localStorage.getItem("wplace_language")
|
||
if (saved && TEXT[saved]) {
|
||
state.language = saved
|
||
}
|
||
} catch (e) {
|
||
console.warn("Could not load language preference:", e)
|
||
}
|
||
}
|
||
|
||
// BILINGUAL TEXT STRINGS:)
|
||
const 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",
|
||
checkingColors: "🔍 Checking available colors...",
|
||
noColorsFound: "❌ Open the color palette on the site and try again!",
|
||
colorsFound: "✅ {count} available colors found. Ready to upload.",
|
||
loadingImage: "🖼️ Loading image...",
|
||
imageLoaded: "✅ Image loaded with {count} valid pixels",
|
||
imageError: "❌ Error loading image",
|
||
selectPositionAlert: "Paint the first pixel at the location where you want the art to start!",
|
||
waitingPosition: "👆 Waiting for you to paint the reference pixel...",
|
||
positionSet: "✅ Position set successfully!",
|
||
positionTimeout: "❌ Timeout for position selection",
|
||
startPaintingMsg: "🎨 Starting painting...",
|
||
paintingProgress: "🧱 Progress: {painted}/{total} pixels...",
|
||
noCharges: "⌛ No charges. Waiting {time}...",
|
||
paintingStopped: "⏹️ Painting stopped by user",
|
||
paintingComplete: "✅ Painting complete! {count} pixels painted.",
|
||
paintingError: "❌ Error during painting",
|
||
missingRequirements: "❌ Load an image and select a position first",
|
||
progress: "Progress",
|
||
pixels: "Pixels",
|
||
charges: "Charges",
|
||
estimatedTime: "Estimated time",
|
||
initMessage: "Click 'Upload Image' to begin",
|
||
waitingInit: "Waiting for initialization...",
|
||
initializingToken: "🔧 Initializing Turnstile token generator...",
|
||
tokenReady: "✅ Token generator ready - you can now start painting!",
|
||
tokenRetryLater: "⚠️ Token generator will retry when needed",
|
||
resizeSuccess: "✅ Image resized to {width}x{height}",
|
||
paintingPaused: "⏸️ Painting paused at position X: {x}, Y: {y}",
|
||
captchaNeeded: "❗ Token generation failed. Please try again in a moment.",
|
||
saveData: "Save Progress",
|
||
loadData: "Load Progress",
|
||
saveToFile: "Save to File",
|
||
loadFromFile: "Load from File",
|
||
dataManager: "Data Manager",
|
||
autoSaved: "✅ Progress saved automatically",
|
||
dataLoaded: "✅ Progress loaded successfully",
|
||
fileSaved: "✅ Progress saved to file successfully",
|
||
fileLoaded: "✅ Progress loaded from file successfully",
|
||
noSavedData: "❌ No saved progress found",
|
||
savedDataFound: "✅ Saved progress found! Load to continue?",
|
||
savedDate: "Saved on: {date}",
|
||
clickLoadToContinue: "Click 'Load Progress' to continue.",
|
||
fileError: "❌ Error processing file",
|
||
invalidFileFormat: "❌ Invalid file format",
|
||
paintingSpeed: "Painting Speed",
|
||
pixelsPerSecond: "pixels/second",
|
||
speedSetting: "Speed: {speed} pixels/sec",
|
||
settings: "Settings",
|
||
botSettings: "Bot Settings",
|
||
close: "Close",
|
||
language: "Language",
|
||
themeSettings: "Theme Settings",
|
||
themeSettingsDesc: "Choose your preferred color theme for the interface.",
|
||
languageSelectDesc: "Select your preferred language. Changes will take effect immediately.",
|
||
autoCaptcha: "Auto-CAPTCHA Solver (Turnstile)",
|
||
autoCaptchaDesc: "Automatically generates Turnstile tokens using integrated generator. Falls back to browser automation if needed.",
|
||
applySettings: "Apply Settings",
|
||
settingsSaved: "✅ Settings saved successfully!",
|
||
cooldownSettings: "Cooldown Settings",
|
||
waitCharges: "Wait until charges reach",
|
||
captchaSolving: "🔑 Generating Turnstile token...",
|
||
captchaFailed: "❌ Turnstile token generation failed. Trying fallback method...",
|
||
automation: "Automation",
|
||
noChargesThreshold: "⌛ Waiting for charges to reach {threshold}. Currently {current}. Next in {time}...",
|
||
},
|
||
ru: {
|
||
title: "WPlace Авто-Изображение",
|
||
scanColors: "Сканировать цвета",
|
||
uploadImage: "Загрузить изображение",
|
||
resizeImage: "Изменить размер изображения",
|
||
selectPosition: "Выбрать позицию",
|
||
startPainting: "Начать рисование",
|
||
stopPainting: "Остановить рисование",
|
||
checkingColors: "🔍 Проверка доступных цветов...",
|
||
noColorsFound: "❌ Откройте палитру цветов на сайте и попробуйте снова!",
|
||
colorsFound: "✅ Найдено доступных цветов: {count}. Готово к загрузке.",
|
||
loadingImage: "🖼️ Загрузка изображения...",
|
||
imageLoaded: "✅ Изображение загружено, валидных пикселей: {count}",
|
||
imageError: "❌ Ошибка при загрузке изображения",
|
||
selectPositionAlert: "Нарисуйте первый пиксель в месте, откуда начнётся рисунок!",
|
||
waitingPosition: "👆 Ожидание, пока вы нарисуете опорный пиксель...",
|
||
positionSet: "✅ Позиция успешно установлена!",
|
||
positionTimeout: "❌ Время ожидания выбора позиции истекло",
|
||
startPaintingMsg: "🎨 Начинаем рисование...",
|
||
paintingProgress: "🧱 Прогресс: {painted}/{total} пикселей...",
|
||
noCharges: "⌛ Нет зарядов. Ожидание {time}...",
|
||
paintingStopped: "⏹️ Рисование остановлено пользователем",
|
||
paintingComplete: "✅ Рисование завершено! Нарисовано пикселей: {count}.",
|
||
paintingError: "❌ Ошибка во время рисования",
|
||
missingRequirements: "❌ Сначала загрузите изображение и выберите позицию",
|
||
progress: "Прогресс",
|
||
pixels: "Пиксели",
|
||
charges: "Заряды",
|
||
estimatedTime: "Примерное время",
|
||
initMessage: "Нажмите 'Загрузить изображение', чтобы начать",
|
||
waitingInit: "Ожидание инициализации...",
|
||
initializingToken: "🔧 Инициализация генератора Turnstile токенов...",
|
||
tokenReady: "✅ Генератор токенов готов - можете начинать рисование!",
|
||
tokenRetryLater: "⚠️ Генератор токенов повторит попытку при необходимости",
|
||
resizeSuccess: "✅ Изображение изменено до {width}x{height}",
|
||
paintingPaused: "⏸️ Рисование приостановлено на позиции X: {x}, Y: {y}",
|
||
captchaNeeded: "❗ Генерация токена не удалась. Пожалуйста, попробуйте через некоторое время.",
|
||
saveData: "Сохранить прогресс",
|
||
loadData: "Загрузить прогресс",
|
||
saveToFile: "Сохранить в файл",
|
||
loadFromFile: "Загрузить из файла",
|
||
dataManager: "Менеджер данных",
|
||
autoSaved: "✅ Прогресс сохранён автоматически",
|
||
dataLoaded: "✅ Прогресс успешно загружен",
|
||
fileSaved: "✅ Прогресс успешно сохранён в файл",
|
||
fileLoaded: "✅ Прогресс успешно загружен из файла",
|
||
noSavedData: "❌ Сохранённый прогресс не найден",
|
||
savedDataFound: "✅ Найден сохранённый прогресс! Загрузить, чтобы продолжить?",
|
||
savedDate: "Сохранено: {date}",
|
||
clickLoadToContinue: "Нажмите 'Загрузить прогресс', чтобы продолжить.",
|
||
fileError: "❌ Ошибка при обработке файла",
|
||
invalidFileFormat: "❌ Неверный формат файла",
|
||
paintingSpeed: "Скорость рисования",
|
||
pixelsPerSecond: "пикселей/сек",
|
||
speedSetting: "Скорость: {speed} пикс./сек",
|
||
settings: "Настройки",
|
||
botSettings: "Настройки бота",
|
||
close: "Закрыть",
|
||
language: "Язык",
|
||
themeSettings: "Настройки темы",
|
||
themeSettingsDesc: "Выберите предпочтительную цветовую тему интерфейса.",
|
||
languageSelectDesc: "Выберите предпочтительный язык. Изменения вступят в силу немедленно.",
|
||
autoCaptcha: "Авто-решение CAPTCHA (Turnstile)",
|
||
autoCaptchaDesc: "Автоматически генерирует Turnstile токены используя встроенный генератор. Возвращается к автоматизации браузера при необходимости.",
|
||
applySettings: "Применить настройки",
|
||
settingsSaved: "✅ Настройки успешно сохранены!",
|
||
cooldownSettings: "Настройки перезарядки",
|
||
waitCharges: "Ждать до накопления зарядов",
|
||
captchaSolving: "🔑 Генерирую Turnstile токен...",
|
||
captchaFailed: "❌ Не удалось сгенерировать Turnstile токен. Пробую резервный метод...",
|
||
automation: "Автоматизация",
|
||
noChargesThreshold: "⌛ Ожидание зарядов до {threshold}. Сейчас {current}. Следующий через {time}...",
|
||
},
|
||
pt: {
|
||
title: "WPlace Auto-Image",
|
||
scanColors: "Escanear Cores",
|
||
uploadImage: "Upload da Imagem",
|
||
resizeImage: "Redimensionar Imagem",
|
||
selectPosition: "Selecionar Posição",
|
||
startPainting: "Iniciar Pintura",
|
||
stopPainting: "Parar Pintura",
|
||
checkingColors: "🔍 Verificando cores disponíveis...",
|
||
noColorsFound: "❌ Abra a paleta de cores no site e tente novamente!",
|
||
colorsFound: "✅ {count} cores encontradas. Pronto para upload.",
|
||
loadingImage: "🖼️ Carregando imagem...",
|
||
imageLoaded: "✅ Imagem carregada com {count} pixels válidos",
|
||
imageError: "❌ Erro ao carregar imagem",
|
||
selectPositionAlert: "Pinte o primeiro pixel на localização onde deseja que a arte comece!",
|
||
waitingPosition: "👆 Aguardando você pintar o pixel de referência...",
|
||
positionSet: "✅ Posição definida com sucesso!",
|
||
positionTimeout: "❌ Tempo esgotado para selecionar posição",
|
||
startPaintingMsg: "🎨 Iniciando pintura...",
|
||
paintingProgress: "🧱 Progresso: {painted}/{total} pixels...",
|
||
noCharges: "⌛ Sem cargas. Aguardando {time}...",
|
||
paintingStopped: "⏹️ Pintura interromпида pelo usuário",
|
||
paintingComplete: "✅ Pintura concluída! {count} pixels pintados.",
|
||
paintingError: "❌ Erro durante a pintura",
|
||
missingRequirements: "❌ Carregue uma imagem e selecione uma posição primeiro",
|
||
progress: "Progresso",
|
||
pixels: "Pixels",
|
||
charges: "Cargas",
|
||
estimatedTime: "Tempo estimado",
|
||
initMessage: "Clique em 'Upload da Imagem' para começar",
|
||
waitingInit: "Aguardando inicialização...",
|
||
initializingToken: "🔧 Inicializando gerador de tokens Turnstile...",
|
||
tokenReady: "✅ Gerador de tokens pronto - você pode começar a pintar!",
|
||
tokenRetryLater: "⚠️ Gerador de tokens tentará novamente quando necessário",
|
||
resizeSuccess: "✅ Imagem redimensionada для {width}x{height}",
|
||
paintingPaused: "⏸️ Pintura pausada na posição X: {x}, Y: {y}",
|
||
captchaNeeded: "❗ Falha na geração de token. Tente novamente em alguns instantes.",
|
||
saveData: "Salvar Progresso",
|
||
loadData: "Carregar Progresso",
|
||
saveToFile: "Salvar em Arquivo",
|
||
loadFromFile: "Carregar de Arquivo",
|
||
dataManager: "Dados",
|
||
autoSaved: "✅ Progresso salvo automaticamente",
|
||
dataLoaded: "✅ Progresso carregado com sucesso",
|
||
fileSaved: "✅ Salvo em arquivo com sucesso",
|
||
fileLoaded: "✅ Carregado de arquivo com sucesso",
|
||
noSavedData: "❌ Nenhum progresso salvo encontrado",
|
||
savedDataFound: "✅ Progresso salvo encontrado! Carregar para continuar?",
|
||
savedDate: "Salvo em: {date}",
|
||
clickLoadToContinue: "Clique em 'Carregar Progresso' para continuar.",
|
||
fileError: "❌ Erro ao processar arquivo",
|
||
invalidFileFormat: "❌ Formato de arquivo inválido",
|
||
paintingSpeed: "Velocidade de Pintura",
|
||
pixelsPerSecond: "pixels/segundo",
|
||
speedSetting: "Velocidade: {speed} pixels/seg",
|
||
settings: "Configurações",
|
||
botSettings: "Configurações do Bot",
|
||
close: "Fechar",
|
||
language: "Idioma",
|
||
themeSettings: "Configurações de Tema",
|
||
themeSettingsDesc: "Escolha seu tema de cores preferido para a interface.",
|
||
languageSelectDesc: "Selecione seu idioma preferido. As alterações terão efeito imediatamente.",
|
||
autoCaptcha: "Resolvedor de CAPTCHA Automático",
|
||
autoCaptchaDesc: "Tenta resolver o CAPTCHA automaticamente simulando a colocação manual de um pixel quando o token expira.",
|
||
applySettings: "Aplicar Configurações",
|
||
settingsSaved: "✅ Configurações salvas com sucesso!",
|
||
cooldownSettings: "Configurações de Cooldown",
|
||
waitCharges: "Aguardar até as cargas atingirem",
|
||
captchaSolving: "🤖 Tentando resolver o CAPTCHA...",
|
||
captchaFailed: "❌ Falha ao resolver CAPTCHA. Pinte um pixel manualmente.",
|
||
automation: "Automação",
|
||
noChargesThreshold: "⌛ Aguardando cargas atingirem {threshold}. Atual: {current}. Próxima em {time}...",
|
||
},
|
||
vi: {
|
||
title: "WPlace Auto-Image",
|
||
scanColors: "Quét màu",
|
||
uploadImage: "Tải lên hình ảnh",
|
||
resizeImage: "Thay đổi kích thước",
|
||
selectPosition: "Chọn vị trí",
|
||
startPainting: "Bắt đầu vẽ",
|
||
stopPainting: "Dừng vẽ",
|
||
checkingColors: "🔍 Đang kiểm tra màu sắc có sẵn...",
|
||
noColorsFound: "❌ Hãy mở bảng màu trên trang web và thử lại!",
|
||
colorsFound: "✅ Tìm thấy {count} màu. Sẵn sàng để tải lên.",
|
||
loadingImage: "🖼️ Đang tải hình ảnh...",
|
||
imageLoaded: "✅ Đã tải hình ảnh với {count} pixel hợp lệ",
|
||
imageError: "❌ Lỗi khi tải hình ảnh",
|
||
selectPositionAlert: "Vẽ pixel đầu tiên tại vị trí bạn muốn tác phẩm nghệ thuật bắt đầu!",
|
||
waitingPosition: "👆 Đang chờ bạn vẽ pixel tham chiếu...",
|
||
positionSet: "✅ Đã đặt vị trí thành công!",
|
||
positionTimeout: "❌ Hết thời gian chọn vị trí",
|
||
startPaintingMsg: "🎨 Bắt đầu vẽ...",
|
||
paintingProgress: "🧱 Tiến trình: {painted}/{total} pixel...",
|
||
noCharges: "⌛ Không có điện tích. Đang chờ {time}...",
|
||
paintingStopped: "⏹️ Người dùng đã dừng vẽ",
|
||
paintingComplete: "✅ Hoàn thành vẽ! Đã vẽ {count} pixel.",
|
||
paintingError: "❌ Lỗi trong quá trình vẽ",
|
||
missingRequirements: "❌ Hãy tải lên hình ảnh và chọn vị trí trước",
|
||
progress: "Tiến trình",
|
||
pixels: "Pixel",
|
||
charges: "Điện tích",
|
||
estimatedTime: "Thời gian ước tính",
|
||
initMessage: "Nhấp 'Tải lên hình ảnh' để bắt đầu",
|
||
waitingInit: "Đang chờ khởi tạo...",
|
||
initializingToken: "🔧 Đang khởi tạo bộ tạo token Turnstile...",
|
||
tokenReady: "✅ Bộ tạo token đã sẵn sàng - bạn có thể bắt đầu vẽ!",
|
||
tokenRetryLater: "⚠️ Bộ tạo token sẽ thử lại khi cần thiết",
|
||
resizeSuccess: "✅ Đã thay đổi kích thước hình ảnh thành {width}x{height}",
|
||
paintingPaused: "⏸️ Tạm dừng vẽ tại vị trí X: {x}, Y: {y}",
|
||
captchaNeeded: "❗ Tạo token thất bại. Vui lòng thử lại sau.",
|
||
saveData: "Lưu tiến trình",
|
||
loadData: "Tải tiến trình",
|
||
saveToFile: "Lưu vào tệp",
|
||
loadFromFile: "Tải từ tệp",
|
||
dataManager: "Dữ liệu",
|
||
autoSaved: "✅ Đã tự động lưu tiến trình",
|
||
dataLoaded: "✅ Đã tải tiến trình thành công",
|
||
fileSaved: "✅ Đã lưu vào tệp thành công",
|
||
fileLoaded: "✅ Đã tải từ tệp thành công",
|
||
noSavedData: "❌ Không tìm thấy tiến trình đã lưu",
|
||
savedDataFound: "✅ Tìm thấy tiến trình đã lưu! Tải để tiếp tục?",
|
||
savedDate: "Đã lưu vào: {date}",
|
||
clickLoadToContinue: "Nhấp 'Tải tiến trình' để tiếp tục.",
|
||
fileError: "❌ Lỗi khi xử lý tệp",
|
||
invalidFileFormat: "❌ Định dạng tệp không hợp lệ",
|
||
paintingSpeed: "Tốc độ vẽ",
|
||
pixelsPerSecond: "pixel/giây",
|
||
speedSetting: "Tốc độ: {speed} pixel/giây",
|
||
settings: "Cài đặt",
|
||
botSettings: "Cài đặt Bot",
|
||
close: "Đóng",
|
||
language: "Ngôn ngữ",
|
||
themeSettings: "Cài đặt Giao diện",
|
||
themeSettingsDesc: "Chọn chủ đề màu sắc yêu thích cho giao diện.",
|
||
languageSelectDesc: "Chọn ngôn ngữ ưa thích. Thay đổi sẽ có hiệu lực ngay lập tức.",
|
||
autoCaptcha: "Tự động giải CAPTCHA",
|
||
autoCaptchaDesc: "Tự động cố gắng giải CAPTCHA bằng cách mô phỏng việc đặt pixel thủ công khi token hết hạn.",
|
||
applySettings: "Áp dụng cài đặt",
|
||
settingsSaved: "✅ Đã lưu cài đặt thành công!",
|
||
cooldownSettings: "Cài đặt thời gian chờ",
|
||
waitCharges: "Chờ cho đến khi số lần sạc đạt",
|
||
captchaSolving: "🤖 Đang cố gắng giải CAPTCHA...",
|
||
captchaFailed: "❌ Giải CAPTCHA tự động thất bại. Vui lòng vẽ một pixel thủ công.",
|
||
automation: "Tự động hóa",
|
||
noChargesThreshold: "⌛ Đang chờ số lần sạc đạt {threshold}. Hiện tại {current}. Lần tiếp theo trong {time}...",
|
||
},
|
||
fr: {
|
||
title: "WPlace Auto-Image",
|
||
scanColors: "Scanner les couleurs",
|
||
uploadImage: "Télécharger l'image",
|
||
resizeImage: "Redimensionner l'image",
|
||
selectPosition: "Sélectionner la position",
|
||
startPainting: "Commencer à peindre",
|
||
stopPainting: "Arrêter de peindre",
|
||
checkingColors: "🔍 Vérification des couleurs disponibles...",
|
||
noColorsFound: "❌ Ouvrez la palette de couleurs sur le site et réessayez!",
|
||
colorsFound: "✅ {count} couleurs trouvées. Prêt à télécharger.",
|
||
loadingImage: "🖼️ Chargement de l'image...",
|
||
imageLoaded: "✅ Image chargée avec {count} pixels valides",
|
||
imageError: "❌ Erreur lors du chargement de l'image",
|
||
selectPositionAlert: "Peignez le premier pixel à l'endroit où vous voulez que l'art commence!",
|
||
waitingPosition: "👆 En attente que vous peigniez le pixel de référence...",
|
||
positionSet: "✅ Position définie avec succès!",
|
||
positionTimeout: "❌ Délai d'attente pour la sélection de position",
|
||
startPaintingMsg: "🎨 Début de la peinture...",
|
||
paintingProgress: "🧱 Progrès: {painted}/{total} pixels...",
|
||
noCharges: "⌛ Aucune charge. En attente {time}...",
|
||
paintingStopped: "⏹️ Peinture arrêtée par l'utilisateur",
|
||
paintingComplete: "✅ Peinture terminée! {count} pixels peints.",
|
||
paintingError: "❌ Erreur pendant la peinture",
|
||
missingRequirements: "❌ Veuillez charger une image et sélectionner une position d'abord",
|
||
progress: "Progrès",
|
||
pixels: "Pixels",
|
||
charges: "Charges",
|
||
estimatedTime: "Temps estimé",
|
||
initMessage: "Cliquez sur 'Télécharger l'image' pour commencer",
|
||
waitingInit: "En attente d'initialisation...",
|
||
initializingToken: "🔧 Initialisation du générateur de tokens Turnstile...",
|
||
tokenReady: "✅ Générateur de tokens prêt - vous pouvez commencer à peindre!",
|
||
tokenRetryLater: "⚠️ Le générateur de tokens réessaiera si nécessaire",
|
||
resizeSuccess: "✅ Image redimensionnée en {width}x{height}",
|
||
paintingPaused: "⏸️ Peinture en pause à la position X: {x}, Y: {y}",
|
||
captchaNeeded: "❗ Échec de la génération de token. Veuillez réessayer dans un moment.",
|
||
saveData: "Sauvegarder le progrès",
|
||
loadData: "Charger le progrès",
|
||
saveToFile: "Sauvegarder dans un fichier",
|
||
loadFromFile: "Charger depuis un fichier",
|
||
dataManager: "Données",
|
||
autoSaved: "✅ Progrès sauvegardé automatiquement",
|
||
dataLoaded: "✅ Progrès chargé avec succès",
|
||
fileSaved: "✅ Sauvegardé dans un fichier avec succès",
|
||
fileLoaded: "✅ Chargé depuis un fichier avec succès",
|
||
noSavedData: "❌ Aucun progrès sauvegardé trouvé",
|
||
savedDataFound: "✅ Progrès sauvegardé trouvé! Charger pour continuer?",
|
||
savedDate: "Sauvegardé le: {date}",
|
||
clickLoadToContinue: "Cliquez sur 'Charger le progrès' pour continuer.",
|
||
fileError: "❌ Erreur lors du traitement du fichier",
|
||
invalidFileFormat: "❌ Format de fichier invalide",
|
||
paintingSpeed: "Vitesse de peinture",
|
||
pixelsPerSecond: "pixels/seconde",
|
||
speedSetting: "Vitesse: {speed} pixels/sec",
|
||
settings: "Paramètres",
|
||
botSettings: "Paramètres du Bot",
|
||
close: "Fermer",
|
||
language: "Langue",
|
||
themeSettings: "Paramètres de Thème",
|
||
themeSettingsDesc: "Choisissez votre thème de couleurs préféré pour l'interface.",
|
||
languageSelectDesc: "Sélectionnez votre langue préférée. Les changements prendront effet immédiatement.",
|
||
autoCaptcha: "Résolveur de CAPTCHA automatique",
|
||
autoCaptchaDesc: "Tente automatiquement de résoudre le CAPTCHA en simulant un placement manuel de pixel lorsque le jeton expire.",
|
||
applySettings: "Appliquer les paramètres",
|
||
settingsSaved: "✅ Paramètres enregistrés avec succès !",
|
||
cooldownSettings: "Paramètres de recharge",
|
||
waitCharges: "Attendre que les charges atteignent",
|
||
captchaSolving: "🤖 Tentative de résolution du CAPTCHA...",
|
||
captchaFailed: "❌ Échec de l'Auto-CAPTCHA. Peignez un pixel manuellement.",
|
||
automation: "Automatisation",
|
||
noChargesThreshold: "⌛ En attente que les charges atteignent {threshold}. Actuel: {current}. Prochaine dans {time}...",
|
||
},
|
||
id: {
|
||
title: "WPlace Auto-Image",
|
||
scanColors: "Pindai Warna",
|
||
uploadImage: "Unggah Gambar",
|
||
resizeImage: "Ubah Ukuran Gambar",
|
||
selectPosition: "Pilih Posisi",
|
||
startPainting: "Mulai Melukis",
|
||
stopPainting: "Berhenti Melukis",
|
||
checkingColors: "🔍 Memeriksa warna yang tersedia...",
|
||
noColorsFound: "❌ Buka palet warna di situs dan coba lagi!",
|
||
colorsFound: "✅ {count} warna ditemukan. Siap untuk diunggah.",
|
||
loadingImage: "🖼️ Memuat gambar...",
|
||
imageLoaded: "✅ Gambar dimuat dengan {count} piksel valid",
|
||
imageError: "❌ Kesalahan saat memuat gambar",
|
||
selectPositionAlert: "Lukis piksel pertama di lokasi tempat karya seni akan dimulai!",
|
||
waitingPosition: "👆 Menunggu Anda melukis piksel referensi...",
|
||
positionSet: "✅ Posisi berhasil diatur!",
|
||
positionTimeout: "❌ Waktu habis untuk memilih posisi",
|
||
startPaintingMsg: "🎨 Mulai melukis...",
|
||
paintingProgress: "🧱 Progres: {painted}/{total} piksel...",
|
||
noCharges: "⌛ Tidak ada muatan. Menunggu {time}...",
|
||
paintingStopped: "⏹️ Melukis dihentikan oleh pengguna",
|
||
paintingComplete: "✅ Melukis selesai! {count} piksel telah dilukis.",
|
||
paintingError: "❌ Kesalahan selama melukis",
|
||
missingRequirements: "❌ Unggah gambar dan pilih posisi terlebih dahulu",
|
||
progress: "Progres",
|
||
pixels: "Piksel",
|
||
charges: "Muatan",
|
||
estimatedTime: "Perkiraan waktu",
|
||
initMessage: "Klik 'Unggah Gambar' untuk memulai",
|
||
waitingInit: "Menunggu inisialisasi...",
|
||
initializingToken: "🔧 Menginisialisasi generator token Turnstile...",
|
||
tokenReady: "✅ Generator token siap - Anda bisa mulai melukis!",
|
||
tokenRetryLater: "⚠️ Generator token akan mencoba lagi saat diperlukan",
|
||
resizeSuccess: "✅ Gambar berhasil diubah ukurannya menjadi {width}x{height}",
|
||
paintingPaused: "⏸️ Melukis dijeda di posisi X: {x}, Y: {y}",
|
||
captchaNeeded: "❗ Pembuatan token gagal. Silakan coba lagi sebentar lagi.",
|
||
saveData: "Simpan Progres",
|
||
loadData: "Muat Progres",
|
||
saveToFile: "Simpan ke File",
|
||
loadFromFile: "Muat dari File",
|
||
dataManager: "Data",
|
||
autoSaved: "✅ Progres disimpan secara otomatis",
|
||
dataLoaded: "✅ Progres berhasil dimuat",
|
||
fileSaved: "✅ Berhasil disimpan ke file",
|
||
fileLoaded: "✅ Berhasil dimuat dari file",
|
||
noSavedData: "❌ Tidak ditemukan progres yang disimpan",
|
||
savedDataFound: "✅ Progres yang disimpan ditemukan! Muat untuk melanjutkan?",
|
||
savedDate: "Disimpan pada: {date}",
|
||
clickLoadToContinue: "Klik 'Muat Progres' untuk melanjutkan.",
|
||
fileError: "❌ Kesalahan saat memproses file",
|
||
invalidFileFormat: "❌ Format file tidak valid",
|
||
paintingSpeed: "Kecepatan Melukis",
|
||
pixelsPerSecond: "piksel/detik",
|
||
speedSetting: "Kecepatan: {speed} piksel/detik",
|
||
settings: "Pengaturan",
|
||
botSettings: "Pengaturan Bot",
|
||
close: "Tutup",
|
||
language: "Bahasa",
|
||
themeSettings: "Pengaturan Tema",
|
||
themeSettingsDesc: "Pilih tema warna favorit Anda untuk antarmuka.",
|
||
languageSelectDesc: "Pilih bahasa yang Anda inginkan. Perubahan akan berlaku segera.",
|
||
autoCaptcha: "Penyelesai CAPTCHA Otomatis",
|
||
autoCaptchaDesc: "Mencoba menyelesaikan CAPTCHA secara otomatis dengan mensimulasikan penempatan piksel manual saat token kedaluwarsa.",
|
||
applySettings: "Terapkan Pengaturan",
|
||
settingsSaved: "✅ Pengaturan berhasil disimpan!",
|
||
cooldownSettings: "Pengaturan Cooldown",
|
||
waitCharges: "Tunggu hingga muatan mencapai",
|
||
captchaSolving: "🤖 Mencoba menyelesaikan CAPTCHA...",
|
||
captchaFailed: "❌ Gagal menyelesaikan CAPTCHA. Lukis satu piksel secara manual.",
|
||
automation: "Automasi",
|
||
noChargesThreshold: "⌛ Menunggu muatan mencapai {threshold}. Saat ini: {current}. Berikutnya dalam {time}...",
|
||
},
|
||
tr: {
|
||
title: "WPlace Otomatik-Resim",
|
||
toggleOverlay: "Katmanı Aç/Kapat",
|
||
scanColors: "Renkleri Tara",
|
||
uploadImage: "Resim Yükle",
|
||
resizeImage: "Resmi Yeniden Boyutlandır",
|
||
selectPosition: "Konum Seç",
|
||
startPainting: "Boyamayı Başlat",
|
||
stopPainting: "Boyamayı Durdur",
|
||
checkingColors: "🔍 Uygun renkler kontrol ediliyor...",
|
||
noColorsFound: "❌ Sitede renk paletini açın ve tekrar deneyin!",
|
||
colorsFound: "✅ {count} uygun renk bulundu. Yüklemeye hazır.",
|
||
loadingImage: "🖼️ Resim yükleniyor...",
|
||
imageLoaded: "✅ Resim {count} geçerli piksel ile yüklendi",
|
||
imageError: "❌ Resim yüklenirken hata oluştu",
|
||
selectPositionAlert: "Sanatı başlatmak istediğiniz ilk pikseli boyayın!",
|
||
waitingPosition: "👆 Referans pikseli boyamanız bekleniyor...",
|
||
positionSet: "✅ Konum başarıyla ayarlandı!",
|
||
positionTimeout: "❌ Konum seçme süresi doldu",
|
||
startPaintingMsg: "🎨 Boyama başlatılıyor...",
|
||
paintingProgress: "🧱 İlerleme: {painted}/{total} piksel...",
|
||
noCharges: "⌛ Yeterli hak yok. Bekleniyor {time}...",
|
||
paintingStopped: "⏹️ Boyama kullanıcı tarafından durduruldu",
|
||
paintingComplete: "✅ Boyama tamamlandı! {count} piksel boyandı.",
|
||
paintingError: "❌ Boyama sırasında hata oluştu",
|
||
missingRequirements: "❌ Önce resim yükleyip konum seçmelisiniz",
|
||
progress: "İlerleme",
|
||
pixels: "Pikseller",
|
||
charges: "Haklar",
|
||
estimatedTime: "Tahmini süre",
|
||
initMessage: "Başlamak için 'Resim Yükle'ye tıklayın",
|
||
waitingInit: "Başlatma bekleniyor...",
|
||
resizeSuccess: "✅ Resim {width}x{height} boyutuna yeniden boyutlandırıldı",
|
||
paintingPaused: "⏸️ Boyama duraklatıldı, Konum X: {x}, Y: {y}",
|
||
captchaNeeded: "❗ CAPTCHA gerekli. Devam etmek için bir pikseli manuel olarak boyayın.",
|
||
saveData: "İlerlemeyi Kaydet",
|
||
loadData: "İlerlemeyi Yükle",
|
||
saveToFile: "Dosyaya Kaydet",
|
||
loadFromFile: "Dosyadan Yükle",
|
||
dataManager: "Veri Yöneticisi",
|
||
autoSaved: "✅ İlerleme otomatik olarak kaydedildi",
|
||
dataLoaded: "✅ İlerleme başarıyla yüklendi",
|
||
fileSaved: "✅ İlerleme dosyaya başarıyla kaydedildi",
|
||
fileLoaded: "✅ İlerleme dosyadan başarıyla yüklendi",
|
||
noSavedData: "❌ Kayıtlı ilerleme bulunamadı",
|
||
savedDataFound: "✅ Kayıtlı ilerleme bulundu! Devam etmek için yükleyin.",
|
||
savedDate: "Kaydedilme tarihi: {date}",
|
||
clickLoadToContinue: "Devam etmek için 'İlerlemeyi Yükle'ye tıklayın.",
|
||
fileError: "❌ Dosya işlenirken hata oluştu",
|
||
invalidFileFormat: "❌ Geçersiz dosya formatı",
|
||
paintingSpeed: "Boyama Hızı",
|
||
pixelsPerSecond: "piksel/saniye",
|
||
speedSetting: "Hız: {speed} piksel/sn",
|
||
settings: "Ayarlar",
|
||
botSettings: "Bot Ayarları",
|
||
close: "Kapat",
|
||
language: "Dil",
|
||
themeSettings: "Tema Ayarları",
|
||
themeSettingsDesc: "Arayüz için tercih ettiğiniz renk temasını seçin.",
|
||
languageSelectDesc: "Tercih ettiğiniz dili seçin. Değişiklikler hemen uygulanacaktır.",
|
||
autoCaptcha: "Oto-CAPTCHA Çözücü",
|
||
autoCaptchaDesc: "CAPTCHA süresi dolduğunda manuel piksel yerleştirmeyi taklit ederek otomatik çözmeyi dener.",
|
||
applySettings: "Ayarları Uygula",
|
||
settingsSaved: "✅ Ayarlar başarıyla kaydedildi!",
|
||
cooldownSettings: "Bekleme Süresi Ayarları",
|
||
waitCharges: "Haklar şu seviyeye ulaşana kadar bekle",
|
||
captchaSolving: "🤖 CAPTCHA çözülmeye çalışılıyor...",
|
||
captchaFailed: "❌ Oto-CAPTCHA başarısız oldu. Bir pikseli manuel boyayın.",
|
||
automation: "Otomasyon",
|
||
noChargesThreshold: "⌛ Hakların {threshold} seviyesine ulaşması bekleniyor. Şu anda {current}. Sonraki {time} içinde...",
|
||
},
|
||
zh: {
|
||
title: "WPlace 自动图像",
|
||
toggleOverlay: "切换覆盖层",
|
||
scanColors: "扫描颜色",
|
||
uploadImage: "上传图像",
|
||
resizeImage: "调整大小",
|
||
selectPosition: "选择位置",
|
||
startPainting: "开始绘制",
|
||
stopPainting: "停止绘制",
|
||
checkingColors: "🔍 正在检查可用颜色...",
|
||
noColorsFound: "❌ 请在网站上打开调色板后再试!",
|
||
colorsFound: "✅ 找到 {count} 个可用颜色,准备上传。",
|
||
loadingImage: "🖼️ 正在加载图像...",
|
||
imageLoaded: "✅ 图像已加载,包含 {count} 个有效像素",
|
||
imageError: "❌ 加载图像时出错",
|
||
selectPositionAlert: "请在你想让作品开始的位置绘制第一个像素!",
|
||
waitingPosition: "👆 正在等待你绘制参考像素...",
|
||
positionSet: "✅ 位置设置成功!",
|
||
positionTimeout: "❌ 选择位置超时",
|
||
startPaintingMsg: "🎨 开始绘制...",
|
||
paintingProgress: "🧱 进度: {painted}/{total} 像素...",
|
||
noCharges: "⌛ 无可用次数,等待 {time}...",
|
||
paintingStopped: "⏹️ 已被用户停止",
|
||
paintingComplete: "✅ 绘制完成!共绘制 {count} 个像素。",
|
||
paintingError: "❌ 绘制过程中出错",
|
||
missingRequirements: "❌ 请先加载图像并选择位置",
|
||
progress: "进度",
|
||
pixels: "像素",
|
||
charges: "次数",
|
||
estimatedTime: "预计时间",
|
||
initMessage: "点击“上传图像”开始",
|
||
waitingInit: "正在等待初始化...",
|
||
initializingToken: "🔧 正在初始化 Turnstile 令牌生成器...",
|
||
tokenReady: "✅ 令牌生成器已就绪 - 可以开始绘制!",
|
||
tokenRetryLater: "⚠️ 令牌生成器稍后将重试",
|
||
resizeSuccess: "✅ 图像已调整为 {width}x{height}",
|
||
paintingPaused: "⏸️ 在位置 X: {x}, Y: {y} 暂停",
|
||
captchaNeeded: "❗ 令牌生成失败,请稍后再试。",
|
||
saveData: "保存进度",
|
||
loadData: "加载进度",
|
||
saveToFile: "保存到文件",
|
||
loadFromFile: "从文件加载",
|
||
dataManager: "数据管理",
|
||
autoSaved: "✅ 进度已自动保存",
|
||
dataLoaded: "✅ 进度加载成功",
|
||
fileSaved: "✅ 已成功保存到文件",
|
||
fileLoaded: "✅ 已成功从文件加载",
|
||
noSavedData: "❌ 未找到已保存进度",
|
||
savedDataFound: "✅ 找到已保存进度!是否加载继续?",
|
||
savedDate: "保存时间: {date}",
|
||
clickLoadToContinue: "点击“加载进度”继续。",
|
||
fileError: "❌ 处理文件时出错",
|
||
invalidFileFormat: "❌ 文件格式无效",
|
||
paintingSpeed: "绘制速度",
|
||
pixelsPerSecond: "像素/秒",
|
||
speedSetting: "速度: {speed} 像素/秒",
|
||
settings: "设置",
|
||
botSettings: "机器人设置",
|
||
close: "关闭",
|
||
language: "语言",
|
||
themeSettings: "主题设置",
|
||
themeSettingsDesc: "为界面选择你喜欢的配色主题。",
|
||
languageSelectDesc: "选择你偏好的语言,变更立即生效。",
|
||
autoCaptcha: "自动 CAPTCHA 解决",
|
||
autoCaptchaDesc: "使用集成的生成器自动生成 Turnstile 令牌,必要时回退到浏览器自动化。",
|
||
applySettings: "应用设置",
|
||
settingsSaved: "✅ 设置保存成功!",
|
||
speedOn: "开启",
|
||
speedOff: "关闭",
|
||
cooldownSettings: "冷却设置",
|
||
waitCharges: "等待次数达到",
|
||
captchaSolving: "🔑 正在生成 Turnstile 令牌...",
|
||
captchaFailed: "❌ 令牌生成失败。尝试回退方法...",
|
||
automation: "自动化",
|
||
noChargesThreshold: "⌛ 等待次数达到 {threshold}。当前 {current}。下次在 {time}...",
|
||
},
|
||
"zh-tw": {
|
||
title: "WPlace 自動圖像",
|
||
toggleOverlay: "切換覆蓋層",
|
||
scanColors: "掃描顏色",
|
||
uploadImage: "上傳圖像",
|
||
resizeImage: "調整大小",
|
||
selectPosition: "選擇位置",
|
||
startPainting: "開始繪製",
|
||
stopPainting: "停止繪製",
|
||
checkingColors: "🔍 正在檢查可用顏色...",
|
||
noColorsFound: "❌ 請在網站上打開調色板後再試!",
|
||
colorsFound: "✅ 找到 {count} 個可用顏色,準備上傳。",
|
||
loadingImage: "🖼️ 正在載入圖像...",
|
||
imageLoaded: "✅ 圖像已載入,包含 {count} 個有效像素",
|
||
imageError: "❌ 載入圖像時出錯",
|
||
selectPositionAlert: "請在你想讓作品開始的位置繪製第一個像素!",
|
||
waitingPosition: "👆 正在等待你繪製參考像素...",
|
||
positionSet: "✅ 位置設定成功!",
|
||
positionTimeout: "❌ 選擇位置逾時",
|
||
startPaintingMsg: "🎨 開始繪製...",
|
||
paintingProgress: "🧱 進度: {painted}/{total} 像素...",
|
||
noCharges: "⌛ 無可用次數,等待 {time}...",
|
||
paintingStopped: "⏹️ 已被使用者停止",
|
||
paintingComplete: "✅ 繪製完成!共繪製 {count} 個像素。",
|
||
paintingError: "❌ 繪製過程中出錯",
|
||
missingRequirements: "❌ 請先載入圖像並選擇位置",
|
||
progress: "進度",
|
||
pixels: "像素",
|
||
charges: "次數",
|
||
estimatedTime: "預計時間",
|
||
initMessage: "點擊「上傳圖像」開始",
|
||
waitingInit: "正在等待初始化...",
|
||
initializingToken: "🔧 正在初始化 Turnstile 令牌產生器...",
|
||
tokenReady: "✅ 令牌產生器已就緒 - 可以開始繪製!",
|
||
tokenRetryLater: "⚠️ 令牌產生器稍後將重試",
|
||
resizeSuccess: "✅ 圖像已調整為 {width}x{height}",
|
||
paintingPaused: "⏸️ 在位置 X: {x}, Y: {y} 暫停",
|
||
captchaNeeded: "❗ 令牌產生失敗,請稍後再試。",
|
||
saveData: "儲存進度",
|
||
loadData: "載入進度",
|
||
saveToFile: "儲存至檔案",
|
||
loadFromFile: "從檔案載入",
|
||
dataManager: "資料管理",
|
||
autoSaved: "✅ 進度已自動儲存",
|
||
dataLoaded: "✅ 進度載入成功",
|
||
fileSaved: "✅ 已成功儲存至檔案",
|
||
fileLoaded: "✅ 已成功從檔案載入",
|
||
noSavedData: "❌ 未找到已儲存進度",
|
||
savedDataFound: "✅ 找到已儲存進度!是否載入以繼續?",
|
||
savedDate: "儲存時間: {date}",
|
||
clickLoadToContinue: "點擊「載入進度」繼續。",
|
||
fileError: "❌ 處理檔案時出錯",
|
||
invalidFileFormat: "❌ 檔案格式無效",
|
||
paintingSpeed: "繪製速度",
|
||
pixelsPerSecond: "像素/秒",
|
||
speedSetting: "速度: {speed} 像素/秒",
|
||
settings: "設定",
|
||
botSettings: "機器人設定",
|
||
close: "關閉",
|
||
language: "語言",
|
||
themeSettings: "主題設定",
|
||
themeSettingsDesc: "為介面選擇你喜歡的配色主題。",
|
||
languageSelectDesc: "選擇你偏好的語言,變更立即生效。",
|
||
autoCaptcha: "自動 CAPTCHA 解決",
|
||
autoCaptchaDesc: "使用整合的產生器自動產生 Turnstile 令牌,必要時回退到瀏覽器自動化。",
|
||
applySettings: "套用設定",
|
||
settingsSaved: "✅ 設定儲存成功!",
|
||
speedOn: "開啟",
|
||
speedOff: "關閉",
|
||
cooldownSettings: "冷卻設定",
|
||
waitCharges: "等待次數達到",
|
||
captchaSolving: "🔑 正在產生 Turnstile 令牌...",
|
||
captchaFailed: "❌ 令牌產生失敗。嘗試回退方法...",
|
||
automation: "自動化",
|
||
noChargesThreshold: "⌛ 等待次數達到 {threshold}。目前 {current}。下次在 {time}...",
|
||
},
|
||
ja: {
|
||
title: "WPlace 自動画像",
|
||
toggleOverlay: "オーバーレイ切替",
|
||
scanColors: "色をスキャン",
|
||
uploadImage: "画像をアップロード",
|
||
resizeImage: "画像サイズ変更",
|
||
selectPosition: "位置を選択",
|
||
startPainting: "描画開始",
|
||
stopPainting: "描画停止",
|
||
checkingColors: "🔍 利用可能な色を確認中...",
|
||
noColorsFound: "❌ サイトでカラーパレットを開いて再試行してください!",
|
||
colorsFound: "✅ 利用可能な色 {count} 件を検出。アップロード可能。",
|
||
loadingImage: "🖼️ 画像を読み込み中...",
|
||
imageLoaded: "✅ 画像を読み込みました。有効なピクセル {count}",
|
||
imageError: "❌ 画像の読み込みエラー",
|
||
selectPositionAlert: "作品を開始したい位置に最初のピクセルを置いてください!",
|
||
waitingPosition: "👆 参照ピクセルの描画を待っています...",
|
||
positionSet: "✅ 位置を設定しました!",
|
||
positionTimeout: "❌ 位置選択のタイムアウト",
|
||
startPaintingMsg: "🎨 描画を開始...",
|
||
paintingProgress: "🧱 進捗: {painted}/{total} ピクセル...",
|
||
noCharges: "⌛ チャージなし。{time} 待機...",
|
||
paintingStopped: "⏹️ ユーザーにより停止されました",
|
||
paintingComplete: "✅ 描画完了! {count} ピクセル描画。",
|
||
paintingError: "❌ 描画中にエラー",
|
||
missingRequirements: "❌ 先に画像を読み込み位置を選択してください",
|
||
progress: "進捗",
|
||
pixels: "ピクセル",
|
||
charges: "チャージ",
|
||
estimatedTime: "推定時間",
|
||
initMessage: "「画像をアップロード」をクリックして開始",
|
||
waitingInit: "初期化待機中...",
|
||
initializingToken: "🔧 Turnstile トークン生成器を初期化中...",
|
||
tokenReady: "✅ トークン生成器準備完了 - 描画できます!",
|
||
tokenRetryLater: "⚠️ 必要に応じて再試行します",
|
||
resizeSuccess: "✅ 画像を {width}x{height} にリサイズ",
|
||
paintingPaused: "⏸️ X: {x}, Y: {y} で一時停止",
|
||
captchaNeeded: "❗ トークン生成に失敗。少ししてから再試行してください。",
|
||
saveData: "進捗を保存",
|
||
loadData: "進捗を読み込み",
|
||
saveToFile: "ファイルへ保存",
|
||
loadFromFile: "ファイルから読み込み",
|
||
dataManager: "データ管理",
|
||
autoSaved: "✅ 自動保存しました",
|
||
dataLoaded: "✅ 進捗を読み込みました",
|
||
fileSaved: "✅ ファイルに保存しました",
|
||
fileLoaded: "✅ ファイルから読み込みました",
|
||
noSavedData: "❌ 保存された進捗がありません",
|
||
savedDataFound: "✅ 保存された進捗が見つかりました。続行しますか?",
|
||
savedDate: "保存日時: {date}",
|
||
clickLoadToContinue: "「進捗を読み込み」をクリックして続行。",
|
||
fileError: "❌ ファイル処理エラー",
|
||
invalidFileFormat: "❌ 無効なファイル形式",
|
||
paintingSpeed: "描画速度",
|
||
pixelsPerSecond: "ピクセル/秒",
|
||
speedSetting: "速度: {speed} ピクセル/秒",
|
||
settings: "設定",
|
||
botSettings: "ボット設定",
|
||
close: "閉じる",
|
||
language: "言語",
|
||
themeSettings: "テーマ設定",
|
||
themeSettingsDesc: "インターフェースの好きなカラーテーマを選択。",
|
||
languageSelectDesc: "希望言語を選択。変更は即時反映されます。",
|
||
autoCaptcha: "自動 CAPTCHA ソルバー",
|
||
autoCaptchaDesc: "統合ジェネレーターで Turnstile トークンを自動生成し必要に応じてブラウザ自動化にフォールバック。",
|
||
applySettings: "設定を適用",
|
||
settingsSaved: "✅ 設定を保存しました!",
|
||
speedOn: "オン",
|
||
speedOff: "オフ",
|
||
cooldownSettings: "クールダウン設定",
|
||
waitCharges: "チャージ数が次に達するまで待機",
|
||
captchaSolving: "🔑 Turnstile トークン生成中...",
|
||
captchaFailed: "❌ トークン生成失敗。フォールバックを試行...",
|
||
automation: "自動化",
|
||
noChargesThreshold: "⌛ チャージ {threshold} を待機中。現在 {current}。次は {time} 後...",
|
||
},
|
||
ko: {
|
||
title: "WPlace 자동 이미지",
|
||
toggleOverlay: "오버레이 전환",
|
||
scanColors: "색상 스캔",
|
||
uploadImage: "이미지 업로드",
|
||
resizeImage: "크기 조정",
|
||
selectPosition: "위치 선택",
|
||
startPainting: "그리기 시작",
|
||
stopPainting: "그리기 중지",
|
||
checkingColors: "🔍 사용 가능한 색상 확인 중...",
|
||
noColorsFound: "❌ 사이트에서 색상 팔레트를 연 후 다시 시도하세요!",
|
||
colorsFound: "✅ 사용 가능한 색상 {count}개 발견. 업로드 준비 완료.",
|
||
loadingImage: "🖼️ 이미지 불러오는 중...",
|
||
imageLoaded: "✅ 이미지 로드 완료. 유효 픽셀 {count}개",
|
||
imageError: "❌ 이미지 로드 오류",
|
||
selectPositionAlert: "작품을 시작할 위치에 첫 픽셀을 칠하세요!",
|
||
waitingPosition: "👆 기준 픽셀을 칠할 때까지 대기 중...",
|
||
positionSet: "✅ 위치 설정 완료!",
|
||
positionTimeout: "❌ 위치 선택 시간 초과",
|
||
startPaintingMsg: "🎨 그리기 시작...",
|
||
paintingProgress: "🧱 진행: {painted}/{total} 픽셀...",
|
||
noCharges: "⌛ 사용 가능 횟수 없음. {time} 대기...",
|
||
paintingStopped: "⏹️ 사용자에 의해 중지됨",
|
||
paintingComplete: "✅ 그리기 완료! {count} 픽셀 그렸습니다.",
|
||
paintingError: "❌ 그리는 중 오류",
|
||
missingRequirements: "❌ 먼저 이미지를 불러오고 위치를 선택하세요",
|
||
progress: "진행",
|
||
pixels: "픽셀",
|
||
charges: "횟수",
|
||
estimatedTime: "예상 시간",
|
||
initMessage: "'이미지 업로드'를 클릭하여 시작",
|
||
waitingInit: "초기화 대기 중...",
|
||
initializingToken: "🔧 Turnstile 토큰 생성기 초기화 중...",
|
||
tokenReady: "✅ 토큰 생성 준비 완료 - 그리기를 시작할 수 있습니다!",
|
||
tokenRetryLater: "⚠️ 필요 시 다시 시도합니다",
|
||
resizeSuccess: "✅ 이미지가 {width}x{height} 크기로 조정됨",
|
||
paintingPaused: "⏸️ 위치 X: {x}, Y: {y} 에서 일시 중지",
|
||
captchaNeeded: "❗ 토큰 생성 실패. 잠시 후 다시 시도하세요.",
|
||
saveData: "진행 저장",
|
||
loadData: "진행 불러오기",
|
||
saveToFile: "파일로 저장",
|
||
loadFromFile: "파일에서 불러오기",
|
||
dataManager: "데이터",
|
||
autoSaved: "✅ 진행 자동 저장됨",
|
||
dataLoaded: "✅ 진행 불러오기 성공",
|
||
fileSaved: "✅ 파일 저장 성공",
|
||
fileLoaded: "✅ 파일 불러오기 성공",
|
||
noSavedData: "❌ 저장된 진행 없음",
|
||
savedDataFound: "✅ 저장된 진행 발견! 계속하려면 불러오시겠습니까?",
|
||
savedDate: "저장 시각: {date}",
|
||
clickLoadToContinue: "'진행 불러오기'를 클릭하여 계속.",
|
||
fileError: "❌ 파일 처리 오류",
|
||
invalidFileFormat: "❌ 잘못된 파일 형식",
|
||
paintingSpeed: "그리기 속도",
|
||
pixelsPerSecond: "픽셀/초",
|
||
speedSetting: "속도: {speed} 픽셀/초",
|
||
settings: "설정",
|
||
botSettings: "봇 설정",
|
||
close: "닫기",
|
||
language: "언어",
|
||
themeSettings: "테마 설정",
|
||
themeSettingsDesc: "인터페이스용 선호 색상 테마를 선택하세요.",
|
||
languageSelectDesc: "선호 언어를 선택하세요. 변경 사항은 즉시 적용됩니다.",
|
||
autoCaptcha: "자동 CAPTCHA 해결",
|
||
autoCaptchaDesc: "통합 생성기를 사용해 Turnstile 토큰을 자동 생성하고 필요 시 브라우저 자동화로 폴백.",
|
||
applySettings: "설정 적용",
|
||
settingsSaved: "✅ 설정 저장 완료!",
|
||
speedOn: "켜짐",
|
||
speedOff: "꺼짐",
|
||
cooldownSettings: "쿨다운 설정",
|
||
waitCharges: "횟수가 다음 값에 도달할 때까지 대기",
|
||
captchaSolving: "🔑 Turnstile 토큰 생성 중...",
|
||
captchaFailed: "❌ 토큰 생성 실패. 폴백 시도...",
|
||
automation: "자동화",
|
||
noChargesThreshold: "⌛ 횟수가 {threshold} 에 도달할 때까지 대기 중. 현재 {current}. 다음 {time} 후...",
|
||
},
|
||
}
|
||
|
||
// GLOBAL STATE
|
||
const state = {
|
||
running: false,
|
||
imageLoaded: false,
|
||
processing: false,
|
||
totalPixels: 0,
|
||
paintedPixels: 0,
|
||
availableColors: [],
|
||
activeColorPalette: [], // User-selected colors for conversion
|
||
paintWhitePixels: true, // Default to ON
|
||
currentCharges: 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 },
|
||
estimatedTime: 0,
|
||
language: "en",
|
||
paintingSpeed: CONFIG.PAINTING_SPEED.DEFAULT, // pixels batch size
|
||
batchMode: CONFIG.BATCH_MODE, // "normal" or "random"
|
||
randomBatchMin: CONFIG.RANDOM_BATCH_RANGE.MIN, // Random range minimum
|
||
randomBatchMax: CONFIG.RANDOM_BATCH_RANGE.MAX, // Random range maximum
|
||
cooldownChargeThreshold: CONFIG.COOLDOWN_CHARGE_THRESHOLD,
|
||
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: true,
|
||
// 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,
|
||
// Notification prefs and runtime bookkeeping
|
||
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,
|
||
allAccountsInfo: [],
|
||
isFetchingAllAccounts: false,
|
||
accountIndex: 0,
|
||
}
|
||
|
||
let _updateResizePreview = () => { };
|
||
let _resizeDialogCleanup = null;
|
||
|
||
// --- OVERLAY UPDATE: Optimized OverlayManager class with performance improvements ---
|
||
class OverlayManager {
|
||
constructor() {
|
||
this.isEnabled = false;
|
||
this.startCoords = null; // { region: {x, y}, pixel: {x, y} }
|
||
this.imageBitmap = null;
|
||
this.chunkedTiles = new Map(); // Map<"tileX,tileY", ImageBitmap>
|
||
this.originalTiles = new Map(); // Map<"tileX,tileY", ImageBitmap> store latest original tile bitmaps
|
||
this.originalTilesData = new Map(); // Map<"tileX,tileY", {w,h,data:Uint8ClampedArray}> cache full ImageData for fast pixel reads
|
||
this.tileSize = 1000;
|
||
this.processPromise = null; // Track ongoing processing
|
||
this.lastProcessedHash = null; // Cache invalidation
|
||
this.workerPool = null; // Web worker pool for heavy processing
|
||
}
|
||
|
||
toggle() {
|
||
this.isEnabled = !this.isEnabled;
|
||
console.log(`Overlay ${this.isEnabled ? 'enabled' : 'disabled'}.`);
|
||
return this.isEnabled;
|
||
}
|
||
|
||
enable() { this.isEnabled = true; }
|
||
disable() { this.isEnabled = false; }
|
||
clear() {
|
||
this.disable();
|
||
this.imageBitmap = null;
|
||
this.chunkedTiles.clear();
|
||
this.originalTiles.clear();
|
||
this.originalTilesData.clear();
|
||
this.lastProcessedHash = null;
|
||
if (this.processPromise) {
|
||
this.processPromise = null;
|
||
}
|
||
}
|
||
|
||
async setImage(imageBitmap) {
|
||
this.imageBitmap = imageBitmap;
|
||
this.lastProcessedHash = null; // Invalidate cache
|
||
if (this.imageBitmap && this.startCoords) {
|
||
await this.processImageIntoChunks();
|
||
}
|
||
}
|
||
|
||
async setPosition(startPosition, region) {
|
||
if (!startPosition || !region) {
|
||
this.startCoords = null;
|
||
this.chunkedTiles.clear();
|
||
this.lastProcessedHash = null;
|
||
return;
|
||
}
|
||
this.startCoords = { region, pixel: startPosition };
|
||
this.lastProcessedHash = null; // Invalidate cache
|
||
if (this.imageBitmap) {
|
||
await this.processImageIntoChunks();
|
||
}
|
||
}
|
||
|
||
// Generate hash for cache invalidation
|
||
_generateProcessHash() {
|
||
if (!this.imageBitmap || !this.startCoords) return null;
|
||
const { width, height } = this.imageBitmap;
|
||
const { x: px, y: py } = this.startCoords.pixel;
|
||
const { x: rx, y: ry } = this.startCoords.region;
|
||
return `${width}x${height}_${px},${py}_${rx},${ry}_${state.blueMarbleEnabled}_${state.overlayOpacity}`;
|
||
}
|
||
|
||
// --- OVERLAY UPDATE: Optimized chunking with caching and batch processing ---
|
||
async processImageIntoChunks() {
|
||
if (!this.imageBitmap || !this.startCoords) return;
|
||
|
||
// Check if we're already processing to avoid duplicate work
|
||
if (this.processPromise) {
|
||
return this.processPromise;
|
||
}
|
||
|
||
// Check cache validity
|
||
const currentHash = this._generateProcessHash();
|
||
if (this.lastProcessedHash === currentHash && this.chunkedTiles.size > 0) {
|
||
console.log(`📦 Using cached overlay chunks (${this.chunkedTiles.size} tiles)`);
|
||
return;
|
||
}
|
||
|
||
// Start processing
|
||
this.processPromise = this._doProcessImageIntoChunks();
|
||
try {
|
||
await this.processPromise;
|
||
this.lastProcessedHash = currentHash;
|
||
} finally {
|
||
this.processPromise = null;
|
||
}
|
||
}
|
||
|
||
async _doProcessImageIntoChunks() {
|
||
const startTime = performance.now();
|
||
this.chunkedTiles.clear();
|
||
|
||
const { width: imageWidth, height: imageHeight } = this.imageBitmap;
|
||
const { x: startPixelX, y: startPixelY } = this.startCoords.pixel;
|
||
const { x: startRegionX, y: startRegionY } = this.startCoords.region;
|
||
|
||
const endPixelX = startPixelX + imageWidth;
|
||
const endPixelY = startPixelY + imageHeight;
|
||
|
||
const startTileX = startRegionX + Math.floor(startPixelX / this.tileSize);
|
||
const startTileY = startRegionY + Math.floor(startPixelY / this.tileSize);
|
||
const endTileX = startRegionX + Math.floor(endPixelX / this.tileSize);
|
||
const endTileY = startRegionY + Math.floor(endPixelY / this.tileSize);
|
||
|
||
const totalTiles = (endTileX - startTileX + 1) * (endTileY - startTileY + 1);
|
||
console.log(`🔄 Processing ${totalTiles} overlay tiles...`);
|
||
|
||
// Process tiles in batches to avoid blocking the main thread
|
||
const batchSize = 4; // Process 4 tiles at a time
|
||
const tilesToProcess = [];
|
||
|
||
for (let ty = startTileY; ty <= endTileY; ty++) {
|
||
for (let tx = startTileX; tx <= endTileX; tx++) {
|
||
tilesToProcess.push({ tx, ty });
|
||
}
|
||
}
|
||
|
||
// Process tiles in batches with yielding
|
||
for (let i = 0; i < tilesToProcess.length; i += batchSize) {
|
||
const batch = tilesToProcess.slice(i, i + batchSize);
|
||
|
||
await Promise.all(batch.map(async ({ tx, ty }) => {
|
||
const tileKey = `${tx},${ty}`;
|
||
const chunkBitmap = await this._processTile(tx, ty, imageWidth, imageHeight, startPixelX, startPixelY, startRegionX, startRegionY);
|
||
if (chunkBitmap) {
|
||
this.chunkedTiles.set(tileKey, chunkBitmap);
|
||
}
|
||
}));
|
||
|
||
// Yield control to prevent blocking
|
||
if (i + batchSize < tilesToProcess.length) {
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
}
|
||
}
|
||
|
||
const processingTime = performance.now() - startTime;
|
||
console.log(`✅ Overlay processed ${this.chunkedTiles.size} tiles in ${Math.round(processingTime)}ms`);
|
||
}
|
||
|
||
async _processTile(tx, ty, imageWidth, imageHeight, startPixelX, startPixelY, startRegionX, startRegionY) {
|
||
const tileKey = `${tx},${ty}`;
|
||
|
||
// Calculate the portion of the image that overlaps with this tile
|
||
const imgStartX = (tx - startRegionX) * this.tileSize - startPixelX;
|
||
const imgStartY = (ty - startRegionY) * this.tileSize - startPixelY;
|
||
|
||
// Crop coordinates within the source image
|
||
const sX = Math.max(0, imgStartX);
|
||
const sY = Math.max(0, imgStartY);
|
||
const sW = Math.min(imageWidth - sX, this.tileSize - (sX - imgStartX));
|
||
const sH = Math.min(imageHeight - sY, this.tileSize - (sY - imgStartY));
|
||
|
||
if (sW <= 0 || sH <= 0) return null;
|
||
|
||
// Destination coordinates on the new chunk canvas
|
||
const dX = Math.max(0, -imgStartX);
|
||
const dY = Math.max(0, -imgStartY);
|
||
|
||
const chunkCanvas = new OffscreenCanvas(this.tileSize, this.tileSize);
|
||
const chunkCtx = chunkCanvas.getContext('2d');
|
||
chunkCtx.imageSmoothingEnabled = false;
|
||
|
||
chunkCtx.drawImage(this.imageBitmap, sX, sY, sW, sH, dX, dY, sW, sH);
|
||
|
||
// --- OPTIMIZED: Blue marble effect with faster pixel manipulation ---
|
||
if (state.blueMarbleEnabled) {
|
||
const imageData = chunkCtx.getImageData(dX, dY, sW, sH);
|
||
const data = imageData.data;
|
||
|
||
// Faster pixel manipulation using typed arrays
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const pixelIndex = i / 4;
|
||
const pixelY = Math.floor(pixelIndex / sW);
|
||
const pixelX = pixelIndex % sW;
|
||
|
||
if ((pixelX + pixelY) % 2 === 0 && data[i + 3] > 0) {
|
||
data[i + 3] = 0; // Set alpha to 0
|
||
}
|
||
}
|
||
|
||
chunkCtx.putImageData(imageData, dX, dY);
|
||
}
|
||
|
||
return await chunkCanvas.transferToImageBitmap();
|
||
}
|
||
|
||
// --- OVERLAY UPDATE: Optimized compositing with caching ---
|
||
async processAndRespondToTileRequest(eventData) {
|
||
const { endpoint, blobID, blobData } = eventData;
|
||
|
||
let finalBlob = blobData;
|
||
|
||
if (this.isEnabled && this.chunkedTiles.size > 0) {
|
||
const tileMatch = endpoint.match(/(\d+)\/(\d+)\.png/);
|
||
if (tileMatch) {
|
||
const tileX = parseInt(tileMatch[1], 10);
|
||
const tileY = parseInt(tileMatch[2], 10);
|
||
const tileKey = `${tileX},${tileY}`;
|
||
|
||
const chunkBitmap = this.chunkedTiles.get(tileKey);
|
||
// Also store the original tile bitmap for later pixel color checks
|
||
try {
|
||
const originalBitmap = await createImageBitmap(blobData);
|
||
this.originalTiles.set(tileKey, originalBitmap);
|
||
// Cache full ImageData for fast pixel access (avoid repeated drawImage/getImageData)
|
||
try {
|
||
let canvas, ctx;
|
||
if (typeof OffscreenCanvas !== 'undefined') {
|
||
canvas = new OffscreenCanvas(originalBitmap.width, originalBitmap.height);
|
||
ctx = canvas.getContext('2d');
|
||
} else {
|
||
canvas = document.createElement('canvas');
|
||
canvas.width = originalBitmap.width;
|
||
canvas.height = originalBitmap.height;
|
||
ctx = canvas.getContext('2d');
|
||
}
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(originalBitmap, 0, 0);
|
||
const imgData = ctx.getImageData(0, 0, originalBitmap.width, originalBitmap.height);
|
||
// Store typed array copy to avoid retaining large canvas
|
||
this.originalTilesData.set(tileKey, { w: originalBitmap.width, h: originalBitmap.height, data: new Uint8ClampedArray(imgData.data) });
|
||
} catch (e) {
|
||
// If ImageData extraction fails, still keep the bitmap as fallback
|
||
console.warn('OverlayManager: could not cache ImageData for', tileKey, e);
|
||
}
|
||
} catch (e) {
|
||
console.warn('OverlayManager: could not create original bitmap for', tileKey, e);
|
||
}
|
||
if (chunkBitmap) {
|
||
try {
|
||
// Use faster compositing for better performance
|
||
finalBlob = await this._compositeTileOptimized(blobData, chunkBitmap);
|
||
} catch (e) {
|
||
console.error("Error compositing overlay:", e);
|
||
// Fallback to original tile on error
|
||
finalBlob = blobData;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Send the (possibly modified) blob back to the injected script
|
||
window.postMessage({
|
||
source: 'auto-image-overlay',
|
||
blobID: blobID,
|
||
blobData: finalBlob
|
||
}, '*');
|
||
}
|
||
|
||
// Returns [r,g,b,a] for a pixel inside a region tile (tileX, tileY are region coords)
|
||
async getTilePixelColor(tileX, tileY, pixelX, pixelY) {
|
||
const tileKey = `${tileX},${tileY}`;
|
||
// Prefer cached ImageData if available
|
||
const cached = this.originalTilesData.get(tileKey);
|
||
if (cached && cached.data && cached.w > 0 && cached.h > 0) {
|
||
const x = Math.max(0, Math.min(cached.w - 1, pixelX));
|
||
const y = Math.max(0, Math.min(cached.h - 1, pixelY));
|
||
const idx = (y * cached.w + x) * 4;
|
||
const d = cached.data;
|
||
const a = d[idx + 3];
|
||
const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
|
||
if (a < alphaThresh) {
|
||
// Treat as transparent / unavailable
|
||
// Lightweight debug: show when transparency causes skip (only if verbose enabled)
|
||
if (window._overlayDebug) console.debug('getTilePixelColor: transparent pixel, skipping', tileKey, x, y, a);
|
||
return null;
|
||
}
|
||
return [d[idx], d[idx + 1], d[idx + 2], a];
|
||
}
|
||
|
||
// Fallback: draw stored bitmap to canvas and read single pixel
|
||
const bitmap = this.originalTiles.get(tileKey);
|
||
if (!bitmap) return null;
|
||
|
||
try {
|
||
let canvas, ctx;
|
||
if (typeof OffscreenCanvas !== 'undefined') {
|
||
canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||
ctx = canvas.getContext('2d');
|
||
} else {
|
||
canvas = document.createElement('canvas');
|
||
canvas.width = bitmap.width;
|
||
canvas.height = bitmap.height;
|
||
ctx = canvas.getContext('2d');
|
||
}
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(bitmap, 0, 0);
|
||
|
||
const x = Math.max(0, Math.min(bitmap.width - 1, pixelX));
|
||
const y = Math.max(0, Math.min(bitmap.height - 1, pixelY));
|
||
const data = ctx.getImageData(x, y, 1, 1).data;
|
||
const a = data[3];
|
||
const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
|
||
if (a < alphaThresh) {
|
||
if (window._overlayDebug) console.debug('getTilePixelColor: transparent pixel (fallback), skipping', tileKey, x, y, a);
|
||
return null;
|
||
}
|
||
return [data[0], data[1], data[2], a];
|
||
} catch (e) {
|
||
console.warn('OverlayManager.getTilePixelColor failed for', tileKey, pixelX, pixelY, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async _compositeTileOptimized(originalBlob, overlayBitmap) {
|
||
const originalBitmap = await createImageBitmap(originalBlob);
|
||
const canvas = new OffscreenCanvas(originalBitmap.width, originalBitmap.height);
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Disable antialiasing for pixel-perfect rendering
|
||
ctx.imageSmoothingEnabled = false;
|
||
|
||
// Draw original tile first
|
||
ctx.drawImage(originalBitmap, 0, 0);
|
||
|
||
// Set opacity and draw overlay with optimized blend mode
|
||
ctx.globalAlpha = state.overlayOpacity;
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
ctx.drawImage(overlayBitmap, 0, 0);
|
||
|
||
// Use faster blob conversion with compression settings
|
||
return await canvas.convertToBlob({
|
||
type: 'image/png',
|
||
quality: 0.95 // Slight compression for faster processing
|
||
});
|
||
}
|
||
}
|
||
|
||
const overlayManager = new OverlayManager();
|
||
|
||
// Optimized Turnstile token handling with improved caching and retry logic
|
||
let turnstileToken = null
|
||
let tokenExpiryTime = 0
|
||
let tokenGenerationInProgress = false
|
||
let _resolveToken = null
|
||
let tokenPromise = new Promise((resolve) => { _resolveToken = resolve })
|
||
let retryCount = 0
|
||
const MAX_RETRIES = 10
|
||
const MAX_BATCH_RETRIES = 10 // Maximum attempts for batch sending
|
||
const TOKEN_LIFETIME = 240000 // 4 minutes (tokens typically last 5 min, use 4 for safety)
|
||
|
||
function setTurnstileToken(token) {
|
||
if (_resolveToken) {
|
||
_resolveToken(token)
|
||
_resolveToken = null
|
||
}
|
||
turnstileToken = token
|
||
tokenExpiryTime = Date.now() + TOKEN_LIFETIME
|
||
console.log("✅ Turnstile token set successfully")
|
||
}
|
||
|
||
function isTokenValid() {
|
||
return turnstileToken && Date.now() < tokenExpiryTime
|
||
}
|
||
|
||
function invalidateToken() {
|
||
turnstileToken = null
|
||
tokenExpiryTime = 0
|
||
console.log("🗑️ Token invalidated, will force fresh generation")
|
||
}
|
||
|
||
async function ensureToken(forceRefresh = false) {
|
||
// Return cached token if still valid and not forcing refresh
|
||
if (isTokenValid() && !forceRefresh) {
|
||
return turnstileToken;
|
||
}
|
||
|
||
// Invalidate token if forcing refresh
|
||
if (forceRefresh) invalidateToken();
|
||
|
||
// Avoid multiple simultaneous token generations
|
||
if (tokenGenerationInProgress) {
|
||
console.log("🔄 Token generation already in progress, waiting...");
|
||
await Utils.sleep(2000);
|
||
return isTokenValid() ? turnstileToken : null;
|
||
}
|
||
|
||
tokenGenerationInProgress = true;
|
||
|
||
try {
|
||
console.log("🔄 Token expired or missing, generating new one...");
|
||
const token = await handleCaptchaWithRetry();
|
||
if (token && token.length > 20) {
|
||
setTurnstileToken(token);
|
||
console.log("✅ Token captured and cached successfully");
|
||
return token;
|
||
}
|
||
|
||
console.log("⚠️ Invisible Turnstile failed, forcing browser automation...");
|
||
const fallbackToken = await handleCaptchaFallback();
|
||
if (fallbackToken && fallbackToken.length > 20) {
|
||
setTurnstileToken(fallbackToken);
|
||
console.log("✅ Fallback token captured successfully");
|
||
return fallbackToken;
|
||
}
|
||
|
||
console.log("❌ All token generation methods failed");
|
||
return null;
|
||
} finally {
|
||
tokenGenerationInProgress = false;
|
||
}
|
||
}
|
||
|
||
async function handleCaptchaWithRetry() {
|
||
const startTime = Date.now();
|
||
try {
|
||
const sitekey = Utils.detectSitekey();
|
||
console.log("🔑 Generating Turnstile token for sitekey:", sitekey);
|
||
|
||
if (typeof window !== "undefined" && window.navigator) {
|
||
console.log("🧭 UA:", window.navigator.userAgent, "Platform:", window.navigator.platform);
|
||
}
|
||
|
||
const token = await Utils.generatePaintToken(sitekey);
|
||
if (token && token.length > 20) {
|
||
const elapsed = Math.round(Date.now() - startTime);
|
||
console.log(`✅ Turnstile token generated successfully in ${elapsed}ms`);
|
||
return token;
|
||
} else {
|
||
throw new Error("Invalid or empty token received");
|
||
}
|
||
} catch (error) {
|
||
const elapsed = Math.round(Date.now() - startTime);
|
||
console.log(`❌ Turnstile token generation failed after ${elapsed}ms:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
const randStr = (len, chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') =>
|
||
[...Array(len)].map(() => chars[(crypto?.getRandomValues?.(new Uint32Array(1))[0] % chars.length) || Math.floor(Math.random() * chars.length)]).join('')
|
||
async function handleCaptchaFallback() {
|
||
// Implementation for fallback token generation would go here
|
||
// This is a placeholder for browser automation fallback
|
||
console.log("🔄 Attempting fallback token generation...");
|
||
return null;
|
||
}
|
||
|
||
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;
|
||
window.fetch = async function (...args) {
|
||
const response = await originalFetch.apply(this, args);
|
||
const url = (args[0] instanceof Request) ? args[0].url : args[0];
|
||
|
||
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:", payload.t);
|
||
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, token } = event.data;
|
||
|
||
if (source === 'auto-image-tile' && endpoint && blobID && blobData) {
|
||
overlayManager.processAndRespondToTileRequest(event.data);
|
||
}
|
||
|
||
if (source === 'turnstile-capture' && token) {
|
||
setTurnstileToken(token);
|
||
if (document.querySelector("#statusText")?.textContent.includes("CAPTCHA")) {
|
||
Utils.showAlert("Token captured successfully! You can start the bot now.", "success");
|
||
updateUI("colorsFound", "success", { count: state.availableColors.length });
|
||
}
|
||
}
|
||
});
|
||
|
||
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
|
||
const Utils = {
|
||
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||
|
||
waitForSelector: async (selector, interval = 200, timeout = 5000) => {
|
||
const start = Date.now();
|
||
while (Date.now() - start < timeout) {
|
||
const el = document.querySelector(selector);
|
||
if (el) return el;
|
||
await Utils.sleep(interval);
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Turnstile Generator Integration - Optimized with widget reuse and proper cleanup
|
||
turnstileLoaded: false,
|
||
_turnstileContainer: null,
|
||
_turnstileOverlay: null,
|
||
_turnstileWidgetId: null,
|
||
_lastSitekey: null,
|
||
|
||
async loadTurnstile() {
|
||
// If Turnstile is already present, just resolve.
|
||
if (window.turnstile) {
|
||
this.turnstileLoaded = true;
|
||
return Promise.resolve();
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// Avoid adding the script twice
|
||
if (document.querySelector('script[src^="https://challenges.cloudflare.com/turnstile/v0/api.js"]')) {
|
||
const checkReady = () => {
|
||
if (window.turnstile) {
|
||
this.turnstileLoaded = true;
|
||
resolve();
|
||
} else {
|
||
setTimeout(checkReady, 100);
|
||
}
|
||
};
|
||
return checkReady();
|
||
}
|
||
|
||
const script = document.createElement('script');
|
||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||
script.async = true;
|
||
script.defer = true;
|
||
script.onload = () => {
|
||
this.turnstileLoaded = true;
|
||
console.log("✅ Turnstile script loaded successfully");
|
||
resolve();
|
||
};
|
||
script.onerror = () => {
|
||
console.error("❌ Failed to load Turnstile script");
|
||
reject(new Error('Failed to load Turnstile'));
|
||
};
|
||
document.head.appendChild(script);
|
||
});
|
||
},
|
||
|
||
// Create or reuse the turnstile container - completely hidden for token generation
|
||
ensureTurnstileContainer() {
|
||
if (!this._turnstileContainer || !document.body.contains(this._turnstileContainer)) {
|
||
// Clean up old container if it exists
|
||
if (this._turnstileContainer) {
|
||
this._turnstileContainer.remove();
|
||
}
|
||
|
||
this._turnstileContainer = document.createElement('div');
|
||
this._turnstileContainer.style.cssText = `
|
||
position: fixed !important;
|
||
left: -99999px !important;
|
||
top: -99999px !important;
|
||
width: 1px !important;
|
||
height: 1px !important;
|
||
pointer-events: none !important;
|
||
opacity: 0 !important;
|
||
visibility: hidden !important;
|
||
z-index: -99999 !important;
|
||
overflow: hidden !important;
|
||
`;
|
||
this._turnstileContainer.setAttribute('aria-hidden', 'true');
|
||
this._turnstileContainer.id = 'turnstile-widget-container';
|
||
document.body.appendChild(this._turnstileContainer);
|
||
}
|
||
return this._turnstileContainer;
|
||
},
|
||
|
||
// Interactive overlay container for visible widgets when needed
|
||
ensureTurnstileOverlayContainer() {
|
||
if (this._turnstileOverlay && document.body.contains(this._turnstileOverlay)) {
|
||
return this._turnstileOverlay;
|
||
}
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'turnstile-overlay-container';
|
||
overlay.style.cssText = `
|
||
position: fixed !important;
|
||
bottom: 20px !important;
|
||
right: 20px !important;
|
||
z-index: 99999 !important;
|
||
background: rgba(0,0,0,0.9) !important;
|
||
border-radius: 12px !important;
|
||
padding: 20px !important;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.4) !important;
|
||
backdrop-filter: blur(10px) !important;
|
||
border: 1px solid rgba(255,255,255,0.2) !important;
|
||
color: white !important;
|
||
font-family: 'Segoe UI', sans-serif !important;
|
||
display: none !important;
|
||
max-width: 350px !important;
|
||
min-width: 300px !important;
|
||
`;
|
||
|
||
const title = document.createElement('div');
|
||
title.textContent = 'Cloudflare Turnstile — please complete the check if shown';
|
||
title.style.cssText = 'font: 600 12px/1.3 "Segoe UI",sans-serif; margin-bottom: 8px; opacity: 0.9;';
|
||
|
||
const host = document.createElement('div');
|
||
host.id = 'turnstile-overlay-host';
|
||
host.style.cssText = 'width: 100%; min-height: 70px;';
|
||
|
||
const hideBtn = document.createElement('button');
|
||
hideBtn.textContent = 'Hide';
|
||
hideBtn.style.cssText = 'position:absolute; top:6px; right:6px; font-size:11px; background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.2); border-radius:6px; padding:2px 6px; cursor:pointer;';
|
||
hideBtn.addEventListener('click', () => overlay.remove());
|
||
|
||
overlay.appendChild(title);
|
||
overlay.appendChild(host);
|
||
overlay.appendChild(hideBtn);
|
||
document.body.appendChild(overlay);
|
||
|
||
this._turnstileOverlay = overlay;
|
||
return overlay;
|
||
},
|
||
|
||
async executeTurnstile(sitekey, action = 'paint') {
|
||
await this.loadTurnstile();
|
||
|
||
// Try reusing existing widget first if sitekey matches
|
||
if (this._turnstileWidgetId && this._lastSitekey === sitekey && window.turnstile?.execute) {
|
||
try {
|
||
console.log("🔄 Reusing existing Turnstile widget...");
|
||
const token = await Promise.race([
|
||
window.turnstile.execute(this._turnstileWidgetId, { action }),
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error('Execute timeout')), 15000))
|
||
]);
|
||
if (token && token.length > 20) {
|
||
console.log("✅ Token generated via widget reuse");
|
||
return token;
|
||
}
|
||
} catch (error) {
|
||
console.log("� Widget reuse failed, will create a fresh widget:", error.message);
|
||
}
|
||
}
|
||
|
||
// Try invisible widget first
|
||
const invisibleToken = await this.createTurnstileWidget(sitekey, action);
|
||
if (invisibleToken && invisibleToken.length > 20) {
|
||
return invisibleToken;
|
||
}
|
||
|
||
console.log("� Falling back to interactive Turnstile (visible).");
|
||
return await this.createTurnstileWidgetInteractive(sitekey, action);
|
||
},
|
||
|
||
async createTurnstileWidget(sitekey, action) {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
// Force cleanup of any existing widget
|
||
if (this._turnstileWidgetId && window.turnstile?.remove) {
|
||
try {
|
||
window.turnstile.remove(this._turnstileWidgetId);
|
||
console.log('🧹 Cleaned up existing Turnstile widget');
|
||
} catch (e) {
|
||
console.warn('⚠️ Widget cleanup warning:', e.message);
|
||
}
|
||
}
|
||
|
||
const container = this.ensureTurnstileContainer();
|
||
container.innerHTML = '';
|
||
|
||
// Verify Turnstile is available
|
||
if (!window.turnstile?.render) {
|
||
console.error('❌ Turnstile not available for rendering');
|
||
resolve(null);
|
||
return;
|
||
}
|
||
|
||
console.log('🔧 Creating invisible Turnstile widget...');
|
||
const widgetId = window.turnstile.render(container, {
|
||
sitekey,
|
||
action,
|
||
size: 'invisible',
|
||
retry: 'auto',
|
||
'retry-interval': 8000,
|
||
callback: (token) => {
|
||
console.log('✅ Invisible Turnstile callback');
|
||
resolve(token);
|
||
},
|
||
'error-callback': () => resolve(null),
|
||
'timeout-callback': () => resolve(null)
|
||
});
|
||
|
||
this._turnstileWidgetId = widgetId;
|
||
this._lastSitekey = sitekey;
|
||
|
||
if (!widgetId) {
|
||
return resolve(null);
|
||
}
|
||
|
||
// Execute the widget and race with timeout
|
||
Promise.race([
|
||
window.turnstile.execute(widgetId, { action }),
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error('Invisible execute timeout')), 12000))
|
||
]).then(resolve).catch(() => resolve(null));
|
||
} catch (e) {
|
||
console.error('❌ Invisible Turnstile creation failed:', e);
|
||
resolve(null);
|
||
}
|
||
});
|
||
},
|
||
|
||
async createTurnstileWidgetInteractive(sitekey, action) {
|
||
// Create a visible widget that users can interact with if needed
|
||
console.log('🔄 Creating interactive Turnstile widget (visible)');
|
||
|
||
return new Promise((resolve) => {
|
||
try {
|
||
// Force cleanup of any existing widget
|
||
if (this._turnstileWidgetId && window.turnstile?.remove) {
|
||
try {
|
||
window.turnstile.remove(this._turnstileWidgetId);
|
||
} catch (e) {
|
||
console.warn('⚠️ Widget cleanup warning:', e.message);
|
||
}
|
||
}
|
||
|
||
const overlay = this.ensureTurnstileOverlayContainer();
|
||
overlay.style.display = 'block';
|
||
|
||
const host = overlay.querySelector('#turnstile-overlay-host');
|
||
host.innerHTML = '';
|
||
|
||
// Set a timeout for interactive mode
|
||
const timeout = setTimeout(() => {
|
||
console.warn('⏰ Interactive Turnstile widget timeout');
|
||
overlay.style.display = 'none';
|
||
resolve(null);
|
||
}, 60000); // 60 seconds for user interaction
|
||
|
||
const widgetId = window.turnstile.render(host, {
|
||
sitekey,
|
||
action,
|
||
size: 'normal',
|
||
theme: 'light',
|
||
callback: (token) => {
|
||
clearTimeout(timeout);
|
||
overlay.style.display = 'none';
|
||
console.log('✅ Interactive Turnstile completed successfully');
|
||
|
||
if (typeof token === 'string' && token.length > 20) {
|
||
resolve(token);
|
||
} else {
|
||
console.warn('❌ Invalid token from interactive widget');
|
||
resolve(null);
|
||
}
|
||
},
|
||
'error-callback': (error) => {
|
||
clearTimeout(timeout);
|
||
overlay.style.display = 'none';
|
||
console.warn('❌ Interactive Turnstile error:', error);
|
||
resolve(null);
|
||
},
|
||
});
|
||
|
||
this._turnstileWidgetId = widgetId;
|
||
this._lastSitekey = sitekey;
|
||
|
||
if (!widgetId) {
|
||
clearTimeout(timeout);
|
||
overlay.style.display = 'none';
|
||
console.warn('❌ Failed to create interactive Turnstile widget');
|
||
resolve(null);
|
||
} else {
|
||
console.log('✅ Interactive Turnstile widget created, waiting for user interaction...');
|
||
}
|
||
} catch (e) {
|
||
console.error('❌ Interactive Turnstile creation failed:', e);
|
||
resolve(null);
|
||
}
|
||
});
|
||
},
|
||
|
||
async generatePaintToken(sitekey) {
|
||
return this.executeTurnstile(sitekey, 'paint');
|
||
},
|
||
|
||
// Cleanup method for when the script is disabled/reloaded
|
||
cleanupTurnstile() {
|
||
if (this._turnstileWidgetId && window.turnstile?.remove) {
|
||
try {
|
||
window.turnstile.remove(this._turnstileWidgetId);
|
||
} catch (e) {
|
||
console.warn('Failed to cleanup Turnstile widget:', e);
|
||
}
|
||
}
|
||
|
||
if (this._turnstileContainer && document.body.contains(this._turnstileContainer)) {
|
||
this._turnstileContainer.remove();
|
||
}
|
||
|
||
if (this._turnstileOverlay && document.body.contains(this._turnstileOverlay)) {
|
||
this._turnstileOverlay.remove();
|
||
}
|
||
|
||
this._turnstileWidgetId = null;
|
||
this._turnstileContainer = null;
|
||
this._turnstileOverlay = null;
|
||
this._lastSitekey = null;
|
||
},
|
||
|
||
detectSitekey(fallback = '0x4AAAAAABpqJe8FO0N84q0F') {
|
||
// Cache sitekey to avoid repeated DOM queries
|
||
if (this._cachedSitekey) {
|
||
console.log("🔍 Using cached sitekey:", this._cachedSitekey);
|
||
return this._cachedSitekey;
|
||
}
|
||
|
||
// List of potential sitekeys to try
|
||
const potentialSitekeys = [
|
||
'0x4AAAAAABpqJe8FO0N84q0F', // WPlace common sitekey
|
||
'0x4AAAAAAAJ7xjKAp6Mt_7zw', // Alternative WPlace sitekey
|
||
'0x4AAAAAADm5QWx6Ov2LNF2g', // Another common sitekey
|
||
];
|
||
|
||
try {
|
||
// Try to find sitekey in data attributes
|
||
const sitekeySel = document.querySelector('[data-sitekey]');
|
||
if (sitekeySel) {
|
||
const sitekey = sitekeySel.getAttribute('data-sitekey');
|
||
if (sitekey && sitekey.length > 10) {
|
||
this._cachedSitekey = sitekey;
|
||
console.log("🔍 Sitekey detected from data attribute:", sitekey);
|
||
return sitekey;
|
||
}
|
||
}
|
||
|
||
// Try turnstile element
|
||
const turnstileEl = document.querySelector('.cf-turnstile');
|
||
if (turnstileEl?.dataset?.sitekey && turnstileEl.dataset.sitekey.length > 10) {
|
||
this._cachedSitekey = turnstileEl.dataset.sitekey;
|
||
console.log("🔍 Sitekey detected from turnstile element:", this._cachedSitekey);
|
||
return this._cachedSitekey;
|
||
}
|
||
|
||
// Try to find sitekey in meta tags
|
||
const metaTags = document.querySelectorAll('meta[name*="turnstile"], meta[property*="turnstile"]');
|
||
for (const meta of metaTags) {
|
||
const content = meta.getAttribute('content');
|
||
if (content && content.length > 10) {
|
||
this._cachedSitekey = content;
|
||
console.log("🔍 Sitekey detected from meta tag:", this._cachedSitekey);
|
||
return this._cachedSitekey;
|
||
}
|
||
}
|
||
|
||
// Try global variable
|
||
if (typeof window !== 'undefined' && window.__TURNSTILE_SITEKEY && window.__TURNSTILE_SITEKEY.length > 10) {
|
||
this._cachedSitekey = window.__TURNSTILE_SITEKEY;
|
||
console.log("🔍 Sitekey detected from global variable:", this._cachedSitekey);
|
||
return this._cachedSitekey;
|
||
}
|
||
|
||
// Try script tags for inline sitekey
|
||
const scripts = document.querySelectorAll('script');
|
||
for (const script of scripts) {
|
||
const content = script.textContent || script.innerHTML;
|
||
const sitekeyMatch = content.match(/sitekey['":\s]+(['"0-9a-zA-X_-]{20,})/i);
|
||
if (sitekeyMatch && sitekeyMatch[1] && sitekeyMatch[1].length > 10) {
|
||
this._cachedSitekey = sitekeyMatch[1].replace(/['"]/g, '');
|
||
console.log("🔍 Sitekey detected from script content:", this._cachedSitekey);
|
||
return this._cachedSitekey;
|
||
}
|
||
}
|
||
|
||
// If no sitekey found through detection, try the known working sitekeys
|
||
console.log("🔍 No sitekey detected, trying known working sitekeys...");
|
||
for (const testSitekey of potentialSitekeys) {
|
||
console.log("🔍 Trying sitekey:", testSitekey);
|
||
this._cachedSitekey = testSitekey;
|
||
return testSitekey;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.warn('Error detecting sitekey:', error);
|
||
}
|
||
|
||
console.log("🔍 Using fallback sitekey:", fallback);
|
||
this._cachedSitekey = fallback;
|
||
return fallback;
|
||
},
|
||
|
||
createElement: (tag, props = {}, children = []) => {
|
||
const element = document.createElement(tag)
|
||
|
||
Object.entries(props).forEach(([key, value]) => {
|
||
if (key === 'style' && typeof value === 'object') {
|
||
Object.assign(element.style, value)
|
||
} else if (key === 'className') {
|
||
element.className = value
|
||
} else if (key === 'innerHTML') {
|
||
element.innerHTML = value
|
||
} else {
|
||
element.setAttribute(key, value)
|
||
}
|
||
})
|
||
|
||
if (typeof children === 'string') {
|
||
element.textContent = children
|
||
} else if (Array.isArray(children)) {
|
||
children.forEach(child => {
|
||
if (typeof child === 'string') {
|
||
element.appendChild(document.createTextNode(child))
|
||
} else {
|
||
element.appendChild(child)
|
||
}
|
||
})
|
||
}
|
||
|
||
return element
|
||
},
|
||
|
||
createButton: (id, text, icon, onClick, style = CONFIG.CSS_CLASSES.BUTTON_PRIMARY) => {
|
||
const button = Utils.createElement('button', {
|
||
id: id,
|
||
style: style,
|
||
innerHTML: `${icon ? `<i class="${icon}"></i>` : ''}<span>${text}</span>`
|
||
})
|
||
if (onClick) button.addEventListener('click', onClick)
|
||
return button
|
||
},
|
||
|
||
t: (key, params = {}) => {
|
||
let text = TEXT[state.language]?.[key] || TEXT.en[key] || key
|
||
Object.keys(params).forEach((param) => {
|
||
text = text.replace(`{${param}}`, params[param])
|
||
})
|
||
return text
|
||
},
|
||
|
||
showAlert: (message, type = "info") => {
|
||
const alertDiv = document.createElement("div")
|
||
alertDiv.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
padding: 12px 20px;
|
||
border-radius: 8px;
|
||
color: white;
|
||
font-weight: 600;
|
||
z-index: 10001;
|
||
max-width: 400px;
|
||
text-align: center;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||
animation: slideDown 0.3s ease-out;
|
||
font-family: 'Segoe UI', sans-serif;
|
||
`
|
||
|
||
const colors = {
|
||
info: "background: linear-gradient(135deg, #3498db, #2980b9);",
|
||
success: "background: linear-gradient(135deg, #27ae60, #229954);",
|
||
warning: "background: linear-gradient(135deg, #f39c12, #e67e22);",
|
||
error: "background: linear-gradient(135deg, #e74c3c, #c0392b);",
|
||
}
|
||
|
||
alertDiv.style.cssText += colors[type] || colors.info
|
||
|
||
const style = document.createElement("style")
|
||
style.textContent = `
|
||
@keyframes slideDown {
|
||
from { transform: translateX(-50%) translateY(-20px); opacity: 0; }
|
||
to { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||
}
|
||
`
|
||
document.head.appendChild(style)
|
||
|
||
alertDiv.textContent = message
|
||
document.body.appendChild(alertDiv)
|
||
|
||
setTimeout(() => {
|
||
alertDiv.style.animation = "slideDown 0.3s ease-out reverse"
|
||
setTimeout(() => {
|
||
document.body.removeChild(alertDiv)
|
||
document.head.removeChild(style)
|
||
}, 300)
|
||
}, 4000)
|
||
},
|
||
|
||
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)),
|
||
_labCache: new Map(), // key: (r<<16)|(g<<8)|b value: [L,a,b]
|
||
_rgbToLab: (r, g, b) => {
|
||
// sRGB -> linear
|
||
const srgbToLinear = (v) => {
|
||
v /= 255;
|
||
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||
};
|
||
const rl = srgbToLinear(r);
|
||
const gl = srgbToLinear(g);
|
||
const bl = srgbToLinear(b);
|
||
let X = rl * 0.4124 + gl * 0.3576 + bl * 0.1805;
|
||
let Y = rl * 0.2126 + gl * 0.7152 + bl * 0.0722;
|
||
let Z = rl * 0.0193 + gl * 0.1192 + bl * 0.9505;
|
||
X /= 0.95047;
|
||
Y /= 1.00000;
|
||
Z /= 1.08883;
|
||
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : (7.787 * t) + 16 / 116);
|
||
const fX = f(X), fY = f(Y), fZ = f(Z);
|
||
const L = 116 * fY - 16;
|
||
const a = 500 * (fX - fY);
|
||
const b2 = 200 * (fY - fZ);
|
||
return [L, a, b2];
|
||
},
|
||
_lab: (r, g, b) => {
|
||
const key = (r << 16) | (g << 8) | b;
|
||
let v = Utils._labCache.get(key);
|
||
if (!v) {
|
||
v = Utils._rgbToLab(r, g, b);
|
||
Utils._labCache.set(key, v);
|
||
}
|
||
return v;
|
||
},
|
||
findClosestPaletteColor: (r, g, b, palette) => {
|
||
// Use provided palette or derive from COLOR_MAP
|
||
if (!palette || palette.length === 0) {
|
||
palette = Object.values(CONFIG.COLOR_MAP)
|
||
.filter(c => c.rgb)
|
||
.map(c => [c.rgb.r, c.rgb.g, c.rgb.b]);
|
||
}
|
||
if (state.colorMatchingAlgorithm === 'legacy') {
|
||
let menorDist = Infinity;
|
||
let cor = [0, 0, 0];
|
||
for (let i = 0; i < palette.length; i++) {
|
||
const [pr, pg, pb] = palette[i];
|
||
const rmean = (pr + r) / 2;
|
||
const rdiff = pr - r;
|
||
const gdiff = pg - g;
|
||
const bdiff = pb - b;
|
||
const dist = Math.sqrt(((512 + rmean) * rdiff * rdiff >> 8) + 4 * gdiff * gdiff + ((767 - rmean) * bdiff * bdiff >> 8));
|
||
if (dist < menorDist) {
|
||
menorDist = dist;
|
||
cor = [pr, pg, pb];
|
||
}
|
||
}
|
||
return cor;
|
||
}
|
||
// LAB algorithm
|
||
const [Lt, at, bt] = Utils._lab(r, g, b);
|
||
const targetChroma = Math.sqrt(at * at + bt * bt);
|
||
let best = null;
|
||
let bestDist = Infinity;
|
||
for (let i = 0; i < palette.length; i++) {
|
||
const [pr, pg, pb] = palette[i];
|
||
const [Lp, ap, bp] = Utils._lab(pr, pg, pb);
|
||
const dL = Lt - Lp;
|
||
const da = at - ap;
|
||
const db = bt - bp;
|
||
let dist = dL * dL + da * da + db * db;
|
||
if (state.enableChromaPenalty && targetChroma > 20) {
|
||
const candChroma = Math.sqrt(ap * ap + bp * bp);
|
||
if (candChroma < targetChroma) {
|
||
const chromaDiff = targetChroma - candChroma;
|
||
dist += chromaDiff * chromaDiff * state.chromaPenaltyWeight;
|
||
}
|
||
}
|
||
if (dist < bestDist) {
|
||
bestDist = dist;
|
||
best = palette[i];
|
||
if (bestDist === 0) break;
|
||
}
|
||
}
|
||
return best || [0, 0, 0];
|
||
},
|
||
|
||
isWhitePixel: (r, g, b) => {
|
||
const wt = state.customWhiteThreshold || CONFIG.WHITE_THRESHOLD;
|
||
return r >= wt && g >= wt && b >= wt;
|
||
},
|
||
|
||
createImageUploader: () =>
|
||
new Promise((resolve) => {
|
||
const input = document.createElement("input")
|
||
input.type = "file"
|
||
input.accept = "image/png,image/jpeg"
|
||
input.onchange = () => {
|
||
const fr = new FileReader()
|
||
fr.onload = () => resolve(fr.result)
|
||
fr.readAsDataURL(input.files[0])
|
||
}
|
||
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)
|
||
},
|
||
|
||
createFileUploader: () =>
|
||
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()
|
||
}),
|
||
|
||
extractAvailableColors: () => {
|
||
const colorElements = document.querySelectorAll('[id^="color-"]')
|
||
|
||
// Separate available and unavailable colors
|
||
const availableColors = []
|
||
const unavailableColors = []
|
||
|
||
Array.from(colorElements).forEach((el) => {
|
||
const id = Number.parseInt(el.id.replace("color-", ""))
|
||
if (id === 0) return // Skip transparent color
|
||
|
||
const rgbStr = el.style.backgroundColor.match(/\d+/g)
|
||
const rgb = rgbStr ? rgbStr.map(Number) : [0, 0, 0]
|
||
|
||
// Find color name from COLOR_MAP
|
||
const colorInfo = Object.values(CONFIG.COLOR_MAP).find(color => color.id === id)
|
||
const name = colorInfo ? colorInfo.name : `Unknown Color ${id}`
|
||
|
||
const colorData = { id, name, rgb }
|
||
|
||
// Check if color is available (no SVG overlay means available)
|
||
if (!el.querySelector("svg")) {
|
||
availableColors.push(colorData)
|
||
} else {
|
||
unavailableColors.push(colorData)
|
||
}
|
||
})
|
||
|
||
// Console log detailed color information
|
||
console.log("=== CAPTURED COLORS STATUS ===")
|
||
console.log(`Total available colors: ${availableColors.length}`)
|
||
console.log(`Total unavailable colors: ${unavailableColors.length}`)
|
||
console.log(`Total colors scanned: ${availableColors.length + unavailableColors.length}`)
|
||
|
||
if (availableColors.length > 0) {
|
||
console.log("\n--- AVAILABLE COLORS ---")
|
||
availableColors.forEach((color, index) => {
|
||
console.log(`${index + 1}. ID: ${color.id}, Name: "${color.name}", RGB: (${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})`)
|
||
})
|
||
}
|
||
|
||
if (unavailableColors.length > 0) {
|
||
console.log("\n--- UNAVAILABLE COLORS ---")
|
||
unavailableColors.forEach((color, index) => {
|
||
console.log(`${index + 1}. ID: ${color.id}, Name: "${color.name}", RGB: (${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]}) [LOCKED]`)
|
||
})
|
||
}
|
||
|
||
console.log("=== END COLOR STATUS ===")
|
||
|
||
return availableColors
|
||
},
|
||
|
||
formatTime: (ms) => {
|
||
const seconds = Math.floor((ms / 1000) % 60)
|
||
const minutes = Math.floor((ms / (1000 * 60)) % 60)
|
||
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24)
|
||
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
|
||
|
||
let result = ""
|
||
if (days > 0) result += `${days}d `
|
||
if (hours > 0 || days > 0) result += `${hours}h `
|
||
if (minutes > 0 || hours > 0 || days > 0) result += `${minutes}m `
|
||
result += `${seconds}s`
|
||
|
||
return result
|
||
},
|
||
|
||
calculateEstimatedTime: (remainingPixels, charges, cooldown) => {
|
||
if (remainingPixels <= 0) return 0
|
||
|
||
const paintingSpeedDelay = state.paintingSpeed > 0 ? (1000 / state.paintingSpeed) : 1000
|
||
const timeFromSpeed = remainingPixels * paintingSpeedDelay
|
||
|
||
const cyclesNeeded = Math.ceil(remainingPixels / Math.max(charges, 1))
|
||
const timeFromCharges = cyclesNeeded * cooldown
|
||
|
||
return Math.max(timeFromSpeed, timeFromCharges)
|
||
},
|
||
|
||
// --- Painted pixel tracking helpers ---
|
||
initializePaintedMap: (width, height) => {
|
||
if (!state.paintedMap || state.paintedMap.length !== height) {
|
||
state.paintedMap = Array(height).fill().map(() => Array(width).fill(false));
|
||
console.log(`📋 Initialized painted map: ${width}x${height}`);
|
||
}
|
||
},
|
||
|
||
markPixelPainted: (x, y, regionX = 0, regionY = 0) => {
|
||
const actualX = x + regionX;
|
||
const actualY = y + regionY;
|
||
|
||
if (state.paintedMap && state.paintedMap[actualY] &&
|
||
actualX >= 0 && actualX < state.paintedMap[actualY].length) {
|
||
state.paintedMap[actualY][actualX] = true;
|
||
}
|
||
},
|
||
|
||
isPixelPainted: (x, y, regionX = 0, regionY = 0) => {
|
||
const actualX = x + regionX;
|
||
const actualY = y + regionY;
|
||
|
||
if (state.paintedMap && state.paintedMap[actualY] &&
|
||
actualX >= 0 && actualX < state.paintedMap[actualY].length) {
|
||
return state.paintedMap[actualY][actualX];
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// Smart save - only save if significant changes
|
||
shouldAutoSave: () => {
|
||
const now = Date.now();
|
||
const pixelsSinceLastSave = state.paintedPixels - state._lastSavePixelCount;
|
||
const timeSinceLastSave = now - state._lastSaveTime;
|
||
|
||
// Save conditions:
|
||
// 1. Every 25 pixels (reduced from 50 for more frequent saves)
|
||
// 2. At least 30 seconds since last save (prevent spam)
|
||
// 3. Not already saving
|
||
return !state._saveInProgress &&
|
||
pixelsSinceLastSave >= 25 &&
|
||
timeSinceLastSave >= 30000;
|
||
},
|
||
|
||
performSmartSave: () => {
|
||
if (!Utils.shouldAutoSave()) return false;
|
||
|
||
state._saveInProgress = true;
|
||
const success = Utils.saveProgress();
|
||
|
||
if (success) {
|
||
state._lastSavePixelCount = state.paintedPixels;
|
||
state._lastSaveTime = Date.now();
|
||
console.log(`💾 Auto-saved at ${state.paintedPixels} pixels`);
|
||
}
|
||
|
||
state._saveInProgress = false;
|
||
return success;
|
||
},
|
||
|
||
// --- Data management helpers ---
|
||
|
||
// Base64 compression helpers for efficient storage
|
||
packPaintedMapToBase64: (paintedMap, width, height) => {
|
||
if (!paintedMap || !width || !height) return null;
|
||
const totalBits = width * height;
|
||
const byteLen = Math.ceil(totalBits / 8);
|
||
const bytes = new Uint8Array(byteLen);
|
||
let bitIndex = 0;
|
||
for (let y = 0; y < height; y++) {
|
||
const row = paintedMap[y];
|
||
for (let x = 0; x < width; x++) {
|
||
const bit = row && row[x] ? 1 : 0;
|
||
const b = bitIndex >> 3; // byte index
|
||
const o = bitIndex & 7; // bit offset
|
||
if (bit) bytes[b] |= (1 << o);
|
||
bitIndex++;
|
||
}
|
||
}
|
||
let binary = "";
|
||
const chunk = 0x8000;
|
||
for (let i = 0; i < bytes.length; i += chunk) {
|
||
binary += String.fromCharCode.apply(null, bytes.subarray(i, Math.min(i + chunk, bytes.length)));
|
||
}
|
||
return btoa(binary);
|
||
},
|
||
|
||
unpackPaintedMapFromBase64: (base64, width, height) => {
|
||
if (!base64 || !width || !height) return null;
|
||
const binary = atob(base64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
const map = Array(height).fill().map(() => Array(width).fill(false));
|
||
let bitIndex = 0;
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
const b = bitIndex >> 3;
|
||
const o = bitIndex & 7;
|
||
map[y][x] = ((bytes[b] >> o) & 1) === 1;
|
||
bitIndex++;
|
||
}
|
||
}
|
||
return map;
|
||
},
|
||
|
||
// Migration helpers for backward compatibility
|
||
migrateProgressToV2: (saved) => {
|
||
if (!saved) return saved;
|
||
const isV1 = !saved.version || saved.version === '1' || saved.version === '1.0' || saved.version === '1.1';
|
||
if (!isV1) return saved;
|
||
|
||
try {
|
||
const migrated = { ...saved };
|
||
const width = migrated.imageData?.width;
|
||
const height = migrated.imageData?.height;
|
||
if (migrated.paintedMap && width && height) {
|
||
const data = Utils.packPaintedMapToBase64(migrated.paintedMap, width, height);
|
||
migrated.paintedMapPacked = { width, height, data };
|
||
}
|
||
delete migrated.paintedMap;
|
||
migrated.version = '2';
|
||
return migrated;
|
||
} catch (e) {
|
||
console.warn('Migration to v2 failed, using original data:', e);
|
||
return saved;
|
||
}
|
||
},
|
||
|
||
migrateProgressToV21: (saved) => {
|
||
if (!saved) return saved;
|
||
if (saved.version === '2.1') return saved;
|
||
const isV2 = saved.version === '2' || saved.version === '2.0';
|
||
const isV1 = !saved.version || saved.version === '1' || saved.version === '1.0' || saved.version === '1.1';
|
||
if (!isV2 && !isV1) return saved; // save this for future
|
||
try {
|
||
const migrated = { ...saved };
|
||
// First migrate to v2 if needed
|
||
if (isV1) {
|
||
const width = migrated.imageData?.width;
|
||
const height = migrated.imageData?.height;
|
||
if (migrated.paintedMap && width && height) {
|
||
const data = Utils.packPaintedMapToBase64(migrated.paintedMap, width, height);
|
||
migrated.paintedMapPacked = { width, height, data };
|
||
}
|
||
delete migrated.paintedMap;
|
||
}
|
||
migrated.version = '2.1';
|
||
return migrated;
|
||
} catch (e) {
|
||
console.warn('Migration to v2.1 failed, using original data:', e);
|
||
return saved;
|
||
}
|
||
},
|
||
|
||
saveProgress: () => {
|
||
try {
|
||
// Pack painted map if available
|
||
let paintedMapPacked = null;
|
||
if (state.paintedMap && state.imageData) {
|
||
const data = Utils.packPaintedMapToBase64(state.paintedMap, state.imageData.width, state.imageData.height);
|
||
if (data) {
|
||
paintedMapPacked = {
|
||
width: state.imageData.width,
|
||
height: state.imageData.height,
|
||
data: data
|
||
};
|
||
}
|
||
}
|
||
|
||
const progressData = {
|
||
timestamp: Date.now(),
|
||
version: "2.1",
|
||
state: {
|
||
totalPixels: state.totalPixels,
|
||
paintedPixels: state.paintedPixels,
|
||
lastPosition: state.lastPosition,
|
||
startPosition: state.startPosition,
|
||
region: state.region,
|
||
imageLoaded: state.imageLoaded,
|
||
colorsChecked: state.colorsChecked,
|
||
availableColors: state.availableColors,
|
||
},
|
||
imageData: state.imageData
|
||
? {
|
||
width: state.imageData.width,
|
||
height: state.imageData.height,
|
||
pixels: Array.from(state.imageData.pixels),
|
||
totalPixels: state.imageData.totalPixels,
|
||
}
|
||
: null,
|
||
paintedMapPacked: paintedMapPacked,
|
||
}
|
||
|
||
localStorage.setItem("wplace-bot-progress", JSON.stringify(progressData))
|
||
return true
|
||
} catch (error) {
|
||
console.error("Error saving progress:", error)
|
||
return false
|
||
}
|
||
},
|
||
|
||
loadProgress: () => {
|
||
try {
|
||
const saved = localStorage.getItem("wplace-bot-progress")
|
||
if (!saved) return null;
|
||
let data = JSON.parse(saved);
|
||
const ver = data.version;
|
||
let migrated = data;
|
||
if (ver === '2.1') {
|
||
// already latest
|
||
} else if (ver === '2' || ver === '2.0') {
|
||
migrated = Utils.migrateProgressToV21(data);
|
||
} else {
|
||
migrated = Utils.migrateProgressToV21(data);
|
||
}
|
||
if (migrated && migrated !== data) {
|
||
try { localStorage.setItem("wplace-bot-progress", JSON.stringify(migrated)); } catch { }
|
||
data = migrated;
|
||
}
|
||
return data;
|
||
} catch (error) {
|
||
console.error("Error loading progress:", error)
|
||
return null
|
||
}
|
||
},
|
||
|
||
clearProgress: () => {
|
||
try {
|
||
localStorage.removeItem("wplace-bot-progress")
|
||
// Also clear painted map from memory
|
||
state.paintedMap = null;
|
||
state._lastSavePixelCount = 0;
|
||
state._lastSaveTime = 0;
|
||
console.log("📋 Progress and painted map cleared");
|
||
return true
|
||
} catch (error) {
|
||
console.error("Error clearing progress:", error)
|
||
return false
|
||
}
|
||
},
|
||
|
||
restoreProgress: (savedData) => {
|
||
try {
|
||
Object.assign(state, savedData.state)
|
||
|
||
if (savedData.imageData) {
|
||
state.imageData = {
|
||
...savedData.imageData,
|
||
pixels: new Uint8ClampedArray(savedData.imageData.pixels),
|
||
}
|
||
|
||
try {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = state.imageData.width;
|
||
canvas.height = state.imageData.height;
|
||
const ctx = canvas.getContext('2d');
|
||
const imageData = new ImageData(state.imageData.pixels, state.imageData.width, state.imageData.height);
|
||
ctx.putImageData(imageData, 0, 0);
|
||
const proc = new ImageProcessor('');
|
||
proc.img = canvas;
|
||
proc.canvas = canvas;
|
||
proc.ctx = ctx;
|
||
state.imageData.processor = proc;
|
||
} catch (e) {
|
||
console.warn('Could not rebuild processor from saved image data:', e);
|
||
}
|
||
}
|
||
|
||
// Prefer packed form if available; fallback to legacy paintedMap array for backward compatibility
|
||
if (savedData.paintedMapPacked && savedData.paintedMapPacked.data) {
|
||
const { width, height, data } = savedData.paintedMapPacked;
|
||
state.paintedMap = Utils.unpackPaintedMapFromBase64(data, width, height);
|
||
} else if (savedData.paintedMap) {
|
||
state.paintedMap = savedData.paintedMap.map((row) => Array.from(row))
|
||
}
|
||
|
||
return true
|
||
} catch (error) {
|
||
console.error("Error restoring progress:", error)
|
||
return false
|
||
}
|
||
},
|
||
|
||
saveProgressToFile: () => {
|
||
try {
|
||
// Pack painted map if available
|
||
let paintedMapPacked = null;
|
||
if (state.paintedMap && state.imageData) {
|
||
const data = Utils.packPaintedMapToBase64(state.paintedMap, state.imageData.width, state.imageData.height);
|
||
if (data) {
|
||
paintedMapPacked = {
|
||
width: state.imageData.width,
|
||
height: state.imageData.height,
|
||
data: data
|
||
};
|
||
}
|
||
}
|
||
|
||
const progressData = {
|
||
timestamp: Date.now(),
|
||
version: "2.1",
|
||
state: {
|
||
totalPixels: state.totalPixels,
|
||
paintedPixels: state.paintedPixels,
|
||
lastPosition: state.lastPosition,
|
||
startPosition: state.startPosition,
|
||
region: state.region,
|
||
imageLoaded: state.imageLoaded,
|
||
colorsChecked: state.colorsChecked,
|
||
availableColors: state.availableColors,
|
||
},
|
||
imageData: state.imageData
|
||
? {
|
||
width: state.imageData.width,
|
||
height: state.imageData.height,
|
||
pixels: Array.from(state.imageData.pixels),
|
||
totalPixels: state.imageData.totalPixels,
|
||
}
|
||
: null,
|
||
paintedMapPacked: paintedMapPacked,
|
||
}
|
||
|
||
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 {
|
||
const data = await Utils.createFileUploader()
|
||
if (!data || !data.state) {
|
||
throw new Error("Invalid file format")
|
||
}
|
||
const ver = data.version;
|
||
let migrated = data;
|
||
if (ver === '2.1') {
|
||
} else if (ver === '2' || ver === '2.0') {
|
||
migrated = Utils.migrateProgressToV21(data) || data;
|
||
} else {
|
||
migrated = Utils.migrateProgressToV21(data) || data;
|
||
}
|
||
const success = Utils.restoreProgress(migrated)
|
||
return success
|
||
} catch (error) {
|
||
console.error("Error loading from file:", error)
|
||
throw error
|
||
}
|
||
},
|
||
|
||
// Helper function to restore overlay from loaded data
|
||
restoreOverlayFromData: async () => {
|
||
if (!state.imageLoaded || !state.imageData || !state.startPosition || !state.region) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// Recreate ImageBitmap from loaded pixel data
|
||
const imageData = new ImageData(
|
||
state.imageData.pixels,
|
||
state.imageData.width,
|
||
state.imageData.height
|
||
);
|
||
|
||
const canvas = new OffscreenCanvas(state.imageData.width, state.imageData.height);
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.putImageData(imageData, 0, 0);
|
||
const imageBitmap = await canvas.transferToImageBitmap();
|
||
|
||
// Set up overlay with restored data
|
||
await overlayManager.setImage(imageBitmap);
|
||
await overlayManager.setPosition(state.startPosition, state.region);
|
||
overlayManager.enable();
|
||
|
||
// Update overlay button state
|
||
const toggleOverlayBtn = document.getElementById('toggleOverlayBtn');
|
||
if (toggleOverlayBtn) {
|
||
toggleOverlayBtn.disabled = false;
|
||
toggleOverlayBtn.classList.add('active');
|
||
}
|
||
|
||
console.log('Overlay restored from data');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Failed to restore overlay from data:', error);
|
||
return false;
|
||
}
|
||
},
|
||
}
|
||
|
||
// IMAGE PROCESSOR CLASS
|
||
class ImageProcessor {
|
||
constructor(imageSrc) {
|
||
this.imageSrc = imageSrc
|
||
this.img = null
|
||
this.canvas = null
|
||
this.ctx = null
|
||
}
|
||
|
||
async load() {
|
||
return new Promise((resolve, reject) => {
|
||
this.img = new Image()
|
||
this.img.crossOrigin = "anonymous"
|
||
this.img.onload = () => {
|
||
this.canvas = document.createElement("canvas")
|
||
this.ctx = this.canvas.getContext("2d")
|
||
this.canvas.width = this.img.width
|
||
this.canvas.height = this.img.height
|
||
this.ctx.drawImage(this.img, 0, 0)
|
||
resolve()
|
||
}
|
||
this.img.onerror = reject
|
||
this.img.src = this.imageSrc
|
||
})
|
||
}
|
||
|
||
getDimensions() {
|
||
return {
|
||
width: this.canvas.width,
|
||
height: this.canvas.height,
|
||
}
|
||
}
|
||
|
||
getPixelData() {
|
||
return this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height).data
|
||
}
|
||
|
||
resize(newWidth, newHeight) {
|
||
const tempCanvas = document.createElement("canvas")
|
||
const tempCtx = tempCanvas.getContext("2d")
|
||
|
||
tempCanvas.width = newWidth
|
||
tempCanvas.height = newHeight
|
||
|
||
tempCtx.imageSmoothingEnabled = false
|
||
tempCtx.drawImage(this.canvas, 0, 0, newWidth, newHeight)
|
||
|
||
this.canvas.width = newWidth
|
||
this.canvas.height = newHeight
|
||
this.ctx.imageSmoothingEnabled = false
|
||
this.ctx.drawImage(tempCanvas, 0, 0)
|
||
|
||
return this.ctx.getImageData(0, 0, newWidth, newHeight).data
|
||
}
|
||
|
||
generatePreview(width, height) {
|
||
const previewCanvas = document.createElement("canvas")
|
||
const previewCtx = previewCanvas.getContext("2d")
|
||
|
||
previewCanvas.width = width
|
||
previewCanvas.height = height
|
||
|
||
previewCtx.imageSmoothingEnabled = false
|
||
previewCtx.drawImage(this.img, 0, 0, width, height)
|
||
|
||
return previewCanvas.toDataURL()
|
||
}
|
||
}
|
||
|
||
// WPLACE API SERVICE
|
||
const WPlaceService = {
|
||
async paintPixelInRegion(regionX, regionY, pixelX, pixelY, color) {
|
||
try {
|
||
await ensureToken()
|
||
if (!turnstileToken) return "token_error"
|
||
const payload = {
|
||
coords: [pixelX, pixelY],
|
||
colors: [color],
|
||
t: turnstileToken,
|
||
fp: randStr(10),
|
||
};
|
||
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.")
|
||
turnstileToken = null
|
||
tokenPromise = new Promise((resolve) => { _resolveToken = resolve })
|
||
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,
|
||
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("Notifications are not supported in this browser.", "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.currentCharges < state.cooldownChargeThreshold;
|
||
state._lastChargesNotifyAt = 0;
|
||
},
|
||
maybeNotifyChargesReached(force = false) {
|
||
if (!state.notificationsEnabled || !state.notifyOnChargesReached) return;
|
||
const reached = state.currentCharges >= 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 = `Charges ready: ${Math.floor(state.currentCharges)} / ${state.maxCharges}. Threshold: ${state.cooldownChargeThreshold}.`;
|
||
this.notify("WPlace — Charges Ready", 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.currentCharges = 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()
|
||
|
||
function findClosestColor(targetRgb, availableColors) {
|
||
if (!availableColors || availableColors.length === 0) return 1
|
||
const cacheKey = `${targetRgb[0]},${targetRgb[1]},${targetRgb[2]}|${state.colorMatchingAlgorithm}|${state.enableChromaPenalty ? 'c' : 'nc'}|${state.chromaPenaltyWeight}`
|
||
if (colorCache.has(cacheKey)) return colorCache.get(cacheKey)
|
||
|
||
const whiteThreshold = state.customWhiteThreshold || CONFIG.WHITE_THRESHOLD
|
||
if (targetRgb[0] >= whiteThreshold && targetRgb[1] >= whiteThreshold && targetRgb[2] >= whiteThreshold) {
|
||
const whiteEntry = availableColors.find(c => c.rgb[0] >= whiteThreshold && c.rgb[1] >= whiteThreshold && c.rgb[2] >= whiteThreshold)
|
||
if (whiteEntry) { colorCache.set(cacheKey, whiteEntry.id); return whiteEntry.id }
|
||
}
|
||
|
||
let bestId = availableColors[0].id
|
||
let bestScore = Infinity
|
||
|
||
if (state.colorMatchingAlgorithm === 'legacy') {
|
||
for (let i = 0; i < availableColors.length; i++) {
|
||
const c = availableColors[i]
|
||
const [r, g, b] = c.rgb
|
||
const rmean = (r + targetRgb[0]) / 2
|
||
const rdiff = r - targetRgb[0]
|
||
const gdiff = g - targetRgb[1]
|
||
const bdiff = b - targetRgb[2]
|
||
const dist = Math.sqrt(((512 + rmean) * rdiff * rdiff >> 8) + 4 * gdiff * gdiff + ((767 - rmean) * bdiff * bdiff >> 8))
|
||
if (dist < bestScore) { bestScore = dist; bestId = c.id; if (dist === 0) break }
|
||
}
|
||
} else { // lab
|
||
const [Lt, at, bt] = Utils._lab(targetRgb[0], targetRgb[1], targetRgb[2])
|
||
const targetChroma = Math.sqrt(at * at + bt * bt)
|
||
const penaltyWeight = state.enableChromaPenalty ? (state.chromaPenaltyWeight || 0.15) : 0
|
||
for (let i = 0; i < availableColors.length; i++) {
|
||
const c = availableColors[i]
|
||
const [r, g, b] = c.rgb
|
||
const [L2, a2, b2] = Utils._lab(r, g, b)
|
||
const dL = Lt - L2, da = at - a2, db = bt - b2
|
||
let dist = dL * dL + da * da + db * db
|
||
if (penaltyWeight > 0 && targetChroma > 20) {
|
||
const candChroma = Math.sqrt(a2 * a2 + b2 * b2)
|
||
if (candChroma < targetChroma) {
|
||
const cd = targetChroma - candChroma
|
||
dist += cd * cd * penaltyWeight
|
||
}
|
||
}
|
||
if (dist < bestScore) { bestScore = dist; bestId = c.id; if (dist === 0) break }
|
||
}
|
||
}
|
||
|
||
colorCache.set(cacheKey, bestId)
|
||
if (colorCache.size > 15000) { const firstKey = colorCache.keys().next().value; colorCache.delete(firstKey) }
|
||
return bestId
|
||
}
|
||
|
||
// UI UPDATE FUNCTIONS (declared early to avoid reference errors)
|
||
let updateUI = () => { }
|
||
let updateStats = async () => {
|
||
localStorage.removeItem("lp");
|
||
const { id, charges, cooldown, max, droplets } = await WPlaceService.getCharges();
|
||
state.currentCharges = Math.floor(charges);
|
||
state.cooldown = cooldown;
|
||
state.maxCharges = Math.floor(max) > 1 ? Math.floor(max) : state.maxCharges;
|
||
NotificationManager.maybeNotifyChargesReached();
|
||
|
||
if (cooldownSlider && cooldownSlider.max != state.maxCharges) {
|
||
cooldownSlider.max = state.maxCharges;
|
||
}
|
||
|
||
let imageStatsHTML = '';
|
||
if (state.imageLoaded) {
|
||
const progress = state.totalPixels > 0 ? Math.round((state.paintedPixels / state.totalPixels) * 100) : 0;
|
||
const remainingPixels = state.totalPixels - state.paintedPixels;
|
||
state.estimatedTime = Utils.calculateEstimatedTime(remainingPixels, state.currentCharges, state.cooldown);
|
||
if (progressBar) 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>
|
||
<div class="wplace-stat-item">
|
||
<div class="wplace-stat-label"><i class="fas fa-clock"></i> ${Utils.t("estimatedTime")}</div>
|
||
<div class="wplace-stat-value">${Utils.formatTime(state.estimatedTime)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
let colorSwatchesHTML = '';
|
||
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="ID: ${color.id}\nRGB: ${color.rgb.join(', ')}"></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
let totalChargesHTML = '';
|
||
if (state.allAccountsInfo && state.allAccountsInfo.length > 0) {
|
||
const totalCharges = state.allAccountsInfo.reduce((sum, acc) => sum + Math.floor(acc.Charges || 0), 0);
|
||
totalChargesHTML = `
|
||
<div class="wplace-stat-item">
|
||
<div class="wplace-stat-label"><i class="fas fa-layer-group"></i> Total Charges</div>
|
||
<div class="wplace-stat-value">${totalCharges}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (statsArea) statsArea.innerHTML = `
|
||
${imageStatsHTML}
|
||
<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">${Math.floor(state.currentCharges)} / ${state.maxCharges}</div>
|
||
</div>
|
||
${totalChargesHTML}
|
||
${state.colorsChecked ? `
|
||
<div class="wplace-colors-section">
|
||
<div class="wplace-stat-label"><i class="fas fa-palette"></i> Available Colors (${state.availableColors.length})</div>
|
||
<div class="wplace-stat-colors-grid">
|
||
${colorSwatchesHTML}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
|
||
// Sync with the accounts list
|
||
if (state.allAccountsInfo && state.allAccountsInfo.length > 0 && id) {
|
||
const currentAccountInList = state.allAccountsInfo.find(acc => acc.ID === id);
|
||
if (currentAccountInList) {
|
||
currentAccountInList.Charges = state.currentCharges;
|
||
currentAccountInList.Max = state.maxCharges;
|
||
currentAccountInList.Droplets = droplets;
|
||
// Re-render the list to show the updated charge count
|
||
renderAccountsList && renderAccountsList();
|
||
}
|
||
}
|
||
}
|
||
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 style="text-align: center; color: #888; padding: 20px;">Upload an image first to capture available colors</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() {
|
||
const startTime = performance.now();
|
||
|
||
// Check user's token source preference
|
||
if (state.tokenSource === "manual") {
|
||
console.log("🎯 Manual token source selected - using pixel placement automation");
|
||
return await handleCaptchaFallback();
|
||
}
|
||
|
||
// Generator mode (pure) or Hybrid mode - try generator first
|
||
try {
|
||
// Use optimized token generation with automatic sitekey detection
|
||
const sitekey = Utils.detectSitekey();
|
||
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();
|
||
}
|
||
|
||
const token = await Utils.generatePaintToken(sitekey);
|
||
|
||
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}`);
|
||
|
||
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 handleCaptchaFallback();
|
||
return fbToken;
|
||
} else {
|
||
// Pure generator mode - don't fallback, just fail
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Keep original method as fallback
|
||
async function handleCaptchaFallback() {
|
||
return new Promise(async (resolve, reject) => {
|
||
try {
|
||
// Ensure we have a fresh promise to await for a new token capture
|
||
if (!_resolveToken) {
|
||
tokenPromise = new Promise((res) => { _resolveToken = res; });
|
||
}
|
||
const timeoutPromise = Utils.sleep(20000).then(() => reject(new Error("Auto-CAPTCHA timed out.")));
|
||
|
||
const solvePromise = (async () => {
|
||
const mainPaintBtn = await Utils.waitForSelector('button.btn.btn-primary.btn-lg, button.btn-primary.sm\\:btn-xl', 200, 10000);
|
||
if (!mainPaintBtn) throw new Error("Could not find the main paint button.");
|
||
mainPaintBtn.click();
|
||
await Utils.sleep(500);
|
||
|
||
const transBtn = await Utils.waitForSelector('button#color-0', 200, 5000);
|
||
if (!transBtn) throw new Error("Could not find the transparent color button.");
|
||
transBtn.click();
|
||
await Utils.sleep(500);
|
||
|
||
const canvas = await Utils.waitForSelector('canvas', 200, 5000);
|
||
if (!canvas) throw new Error("Could not find the canvas element.");
|
||
|
||
canvas.setAttribute('tabindex', '0');
|
||
canvas.focus();
|
||
const rect = canvas.getBoundingClientRect();
|
||
const centerX = Math.round(rect.left + rect.width / 2);
|
||
const centerY = Math.round(rect.top + rect.height / 2);
|
||
|
||
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: centerX, clientY: centerY, bubbles: true }));
|
||
canvas.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
|
||
await Utils.sleep(50);
|
||
canvas.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }));
|
||
await Utils.sleep(500);
|
||
|
||
// 800ms delay before sending confirmation
|
||
await Utils.sleep(800);
|
||
|
||
// Keep confirming until token is captured
|
||
const confirmLoop = async () => {
|
||
while (!turnstileToken) {
|
||
let confirmBtn = await Utils.waitForSelector('button.btn.btn-primary.btn-lg, button.btn.btn-primary.sm\\:btn-xl');
|
||
if (!confirmBtn) {
|
||
const allPrimary = Array.from(document.querySelectorAll('button.btn-primary'));
|
||
confirmBtn = allPrimary.length ? allPrimary[allPrimary.length - 1] : null;
|
||
}
|
||
if (confirmBtn) {
|
||
confirmBtn.click();
|
||
}
|
||
await Utils.sleep(500); // 500ms delay between confirmation attempts
|
||
}
|
||
};
|
||
|
||
// Start confirmation loop and wait for token
|
||
confirmLoop();
|
||
const token = await tokenPromise;
|
||
await Utils.sleep(300); // small delay after token is captured
|
||
resolve(token);
|
||
})();
|
||
|
||
await Promise.race([solvePromise, timeoutPromise]);
|
||
|
||
} catch (error) {
|
||
console.error("Auto-CAPTCHA process failed:", error);
|
||
reject(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()
|
||
loadLanguagePreference()
|
||
|
||
const theme = getCurrentTheme()
|
||
|
||
const fontAwesome = document.createElement("link")
|
||
fontAwesome.rel = "stylesheet"
|
||
fontAwesome.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||
document.head.appendChild(fontAwesome)
|
||
|
||
if (theme.fontFamily.includes("Press Start 2P")) {
|
||
const googleFonts = document.createElement("link")
|
||
googleFonts.rel = "stylesheet"
|
||
googleFonts.href = "https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
|
||
document.head.appendChild(googleFonts)
|
||
}
|
||
|
||
const style = document.createElement("style")
|
||
style.setAttribute("data-wplace-theme", "true")
|
||
|
||
style.textContent = `
|
||
${theme.animations.glow
|
||
? `
|
||
@keyframes neonGlow {
|
||
0%, 100% {
|
||
text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor;
|
||
}
|
||
50% {
|
||
text-shadow: 0 0 2px currentColor, 0 0 5px currentColor, 0 0 8px currentColor;
|
||
}
|
||
}`
|
||
: ""
|
||
}
|
||
|
||
${theme.animations.pixelBlink
|
||
? `
|
||
@keyframes pixelBlink {
|
||
0%, 50% { opacity: 1; }
|
||
51%, 100% { opacity: 0.7; }
|
||
}`
|
||
: ""
|
||
}
|
||
|
||
${theme.animations.scanline
|
||
? `
|
||
@keyframes scanline {
|
||
0% { transform: translateY(-100%); }
|
||
100% { transform: translateY(400px); }
|
||
}`
|
||
: ""
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0% { box-shadow: 0 0 0 0 rgba(0, 255, 0, 0.7); }
|
||
70% { box-shadow: 0 0 0 10px rgba(0, 255, 0, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(0, 255, 0, 0); }
|
||
}
|
||
@keyframes slideIn {
|
||
from { transform: translateY(-10px); opacity: 0; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
@keyframes shimmer {
|
||
0% { transform: translateX(-100%); }
|
||
100% { transform: translateX(100%); }
|
||
}
|
||
|
||
#wplace-image-bot-container {
|
||
position: fixed;
|
||
top: 20px;
|
||
left: 20px;
|
||
width: ${CONFIG.currentTheme === "Neon Retro" ? "294px" : "294px"}; /* Increased by 5% (280 * 1.05 = 294) */
|
||
max-height: calc(100vh - 40px);
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.primary} 0%, #1a1a1a 100%)`
|
||
: theme.primary
|
||
};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${CONFIG.currentTheme === "Classic Autobot" ? theme.accent : theme.text};
|
||
border-radius: ${theme.borderRadius};
|
||
padding: 0;
|
||
box-shadow: ${theme.boxShadow};
|
||
z-index: 9998;
|
||
font-family: ${theme.fontFamily};
|
||
color: ${theme.text};
|
||
animation: slideIn 0.4s ease-out;
|
||
overflow-y: auto; /* Allow scrolling for main panel */
|
||
overflow-x: hidden;
|
||
${theme.backdropFilter ? `backdrop-filter: ${theme.backdropFilter};` : ""}
|
||
transition: all 0.3s ease;
|
||
user-select: none;
|
||
${CONFIG.currentTheme === "Neon Retro" ? "image-rendering: pixelated;" : ""}
|
||
}
|
||
|
||
${theme.animations.scanline
|
||
? `
|
||
#wplace-image-bot-container::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, transparent, ${theme.neon}, transparent);
|
||
animation: scanline 3s linear infinite;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
}`
|
||
: ""
|
||
}
|
||
|
||
${CONFIG.currentTheme === "Neon Retro"
|
||
? `
|
||
#wplace-image-bot-container::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background:
|
||
repeating-linear-gradient(
|
||
0deg,
|
||
transparent,
|
||
transparent 2px,
|
||
rgba(0, 255, 65, 0.03) 2px,
|
||
rgba(0, 255, 65, 0.03) 4px
|
||
);
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}`
|
||
: ""
|
||
}
|
||
|
||
#wplace-image-bot-container.wplace-dragging {
|
||
transition: none;
|
||
box-shadow: 0 12px 40px rgba(0,0,0,0.8), 0 0 0 2px rgba(255,255,255,0.2);
|
||
transform: scale(1.02);
|
||
z-index: 9999;
|
||
}
|
||
#wplace-image-bot-container.wplace-minimized {
|
||
width: 200px;
|
||
height: auto;
|
||
overflow: hidden;
|
||
}
|
||
#wplace-image-bot-container.wplace-compact {
|
||
width: 240px;
|
||
}
|
||
|
||
/* Stats Container */
|
||
#wplace-stats-container {
|
||
position: fixed;
|
||
top: 20px;
|
||
left: 320px;
|
||
width: ${CONFIG.currentTheme === "Neon Retro" ? "350px" : "350px"}; /* Increased further for name visibility */
|
||
max-height: calc(100vh - 40px);
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.primary} 0%, #1a1a1a 100%)`
|
||
: theme.primary
|
||
};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${CONFIG.currentTheme === "Classic Autobot" ? theme.accent : theme.text};
|
||
border-radius: ${theme.borderRadius};
|
||
padding: 0;
|
||
box-shadow: ${theme.boxShadow};
|
||
z-index: 9997;
|
||
font-family: ${theme.fontFamily};
|
||
color: ${theme.text};
|
||
animation: slideIn 0.4s ease-out;
|
||
overflow-y: auto; /* Make stats panel scrollable */
|
||
${theme.backdropFilter ? `backdrop-filter: ${theme.backdropFilter};` : ""}
|
||
transition: all 0.3s ease;
|
||
user-select: none;
|
||
${CONFIG.currentTheme === "Neon Retro" ? "image-rendering: pixelated;" : ""}
|
||
}
|
||
|
||
/* FIX: Disable transition during drag to prevent lag */
|
||
#wplace-stats-container.wplace-dragging {
|
||
transition: none;
|
||
}
|
||
|
||
.wplace-header {
|
||
padding: ${CONFIG.currentTheme === "Neon Retro" ? "8px 12px" : "8px 12px"};
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.secondary} 0%, #2a2a2a 100%)`
|
||
: theme.secondary
|
||
};
|
||
color: ${theme.highlight};
|
||
font-size: ${CONFIG.currentTheme === "Neon Retro" ? "11px" : "13px"};
|
||
font-weight: ${CONFIG.currentTheme === "Neon Retro" ? "normal" : "700"};
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
cursor: move;
|
||
user-select: none;
|
||
border-bottom: ${CONFIG.currentTheme === "Neon Retro" ? "2px" : "1px"} solid ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(255,255,255,0.1)" : theme.text};
|
||
${CONFIG.currentTheme === "Classic Autobot" ? "text-shadow: 0 1px 2px rgba(0,0,0,0.5);" : "text-transform: uppercase; letter-spacing: 1px;"}
|
||
transition: background 0.2s ease;
|
||
position: relative;
|
||
z-index: 2;
|
||
${theme.animations.glow ? "animation: neonGlow 2s ease-in-out infinite alternate;" : ""}
|
||
}
|
||
|
||
.wplace-header-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: ${CONFIG.currentTheme === "Neon Retro" ? "6px" : "6px"};
|
||
}
|
||
|
||
.wplace-header-controls {
|
||
display: flex;
|
||
gap: ${CONFIG.currentTheme === "Neon Retro" ? "6px" : "6px"};
|
||
}
|
||
|
||
.wplace-header-btn {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(255,255,255,0.1)" : theme.accent};
|
||
border: ${CONFIG.currentTheme === "Neon Retro" ? `2px solid ${theme.text}` : "none"};
|
||
color: ${theme.text};
|
||
cursor: pointer;
|
||
border-radius: ${CONFIG.currentTheme === "Classic Autobot" ? "4px" : "0"};
|
||
width: ${CONFIG.currentTheme === "Classic Autobot" ? "18px" : "auto"};
|
||
height: ${CONFIG.currentTheme === "Classic Autobot" ? "18px" : "auto"};
|
||
padding: ${CONFIG.currentTheme === "Neon Retro" ? "4px 6px" : "0"};
|
||
font-size: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "10px"};
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
font-family: ${theme.fontFamily};
|
||
${CONFIG.currentTheme === "Neon Retro" ? "image-rendering: pixelated;" : ""}
|
||
}
|
||
.wplace-header-btn:hover {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? theme.accent : theme.text};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? theme.text : theme.primary};
|
||
transform: ${CONFIG.currentTheme === "Classic Autobot" ? "scale(1.1)" : "none"};
|
||
${CONFIG.currentTheme === "Neon Retro" ? `box-shadow: 0 0 10px ${theme.text};` : ""}
|
||
}
|
||
|
||
.wplace-content {
|
||
padding: ${CONFIG.currentTheme === "Neon Retro" ? "12px" : "12px"};
|
||
display: block;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.wplace-content.wplace-hidden {
|
||
display: none;
|
||
}
|
||
|
||
.wplace-status-section {
|
||
margin-bottom: 12px;
|
||
padding: 8px;
|
||
background: rgba(255,255,255,0.03);
|
||
border-radius: ${theme.borderRadius};
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
}
|
||
|
||
.wplace-section {
|
||
margin-bottom: ${CONFIG.currentTheme === "Neon Retro" ? "12px" : "12px"};
|
||
padding: 12px;
|
||
background: rgba(255,255,255,0.03);
|
||
border-radius: ${theme.borderRadius};
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
}
|
||
|
||
.wplace-section-title {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
color: ${theme.highlight};
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.wplace-controls {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.wplace-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
.wplace-row.single {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.wplace-btn {
|
||
padding: ${CONFIG.currentTheme === "Neon Retro" ? "12px 8px" : "8px 12px"};
|
||
border: ${CONFIG.currentTheme === "Neon Retro" ? "2px solid" : "none"};
|
||
border-radius: ${theme.borderRadius};
|
||
font-weight: ${CONFIG.currentTheme === "Neon Retro" ? "normal" : "500"};
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "6px"};
|
||
font-size: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "11px"};
|
||
transition: all 0.3s ease;
|
||
position: relative;
|
||
overflow: hidden;
|
||
font-family: ${theme.fontFamily};
|
||
${CONFIG.currentTheme === "Neon Retro" ? "text-transform: uppercase; letter-spacing: 1px; image-rendering: pixelated;" : ""}
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.accent} 0%, #4a4a4a 100%)`
|
||
: theme.accent
|
||
};
|
||
${CONFIG.currentTheme === "Classic Autobot" ? "border: 1px solid rgba(255,255,255,0.1);" : ""}
|
||
}
|
||
|
||
${CONFIG.currentTheme === "Classic Autobot"
|
||
? `
|
||
.wplace-btn::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
|
||
transition: left 0.5s ease;
|
||
}
|
||
.wplace-btn:hover:not(:disabled)::before {
|
||
left: 100%;
|
||
}`
|
||
: `
|
||
.wplace-btn::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||
transition: left 0.5s;
|
||
}
|
||
.wplace-btn:hover::before {
|
||
left: 100%;
|
||
}`
|
||
}
|
||
|
||
.wplace-btn:hover:not(:disabled) {
|
||
transform: ${CONFIG.currentTheme === "Classic Autobot" ? "translateY(-1px)" : "none"};
|
||
box-shadow: ${CONFIG.currentTheme === "Classic Autobot" ? "0 4px 12px rgba(0,0,0,0.4)" : "0 0 15px currentColor"
|
||
};
|
||
${theme.animations.pixelBlink ? "animation: pixelBlink 0.5s infinite;" : ""}
|
||
}
|
||
.wplace-btn:active:not(:disabled) {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.wplace-btn-primary {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.accent} 0%, #6a5acd 100%)`
|
||
: theme.accent
|
||
};
|
||
color: ${theme.text};
|
||
${CONFIG.currentTheme === "Neon Retro" ? `border-color: ${theme.text};` : ""}
|
||
}
|
||
.wplace-btn-upload {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.secondary} 0%, #4a4a4a 100%)`
|
||
: theme.purple
|
||
};
|
||
color: ${theme.text};
|
||
${CONFIG.currentTheme === "Classic Autobot"
|
||
? `border: 1px dashed ${theme.highlight};`
|
||
: `border-color: ${theme.text}; border-style: dashed;`
|
||
}
|
||
}
|
||
.wplace-btn-start {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.success} 0%, #228b22 100%)`
|
||
: theme.success
|
||
};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? "white" : theme.primary};
|
||
${CONFIG.currentTheme === "Neon Retro" ? `border-color: ${theme.success};` : ""}
|
||
}
|
||
.wplace-btn-stop {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.error} 0%, #dc143c 100%)`
|
||
: theme.error
|
||
};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? "white" : theme.text};
|
||
${CONFIG.currentTheme === "Neon Retro" ? `border-color: ${theme.error};` : ""}
|
||
}
|
||
.wplace-btn-select {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.highlight} 0%, #9370db 100%)`
|
||
: theme.highlight
|
||
};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? "white" : theme.primary};
|
||
${CONFIG.currentTheme === "Neon Retro" ? `border-color: ${theme.highlight};` : ""}
|
||
}
|
||
.wplace-btn-file {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? "linear-gradient(135deg, #ff8c00 0%, #ff7f50 100%)"
|
||
: theme.warning
|
||
};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? "white" : theme.primary};
|
||
${CONFIG.currentTheme === "Neon Retro" ? `border-color: ${theme.warning};` : ""}
|
||
}
|
||
.wplace-btn:disabled {
|
||
opacity: ${CONFIG.currentTheme === "Classic Autobot" ? "0.5" : "0.3"};
|
||
cursor: not-allowed;
|
||
transform: none !important;
|
||
${theme.animations.pixelBlink ? "animation: none !important;" : ""}
|
||
box-shadow: none !important;
|
||
}
|
||
.wplace-btn:disabled::before {
|
||
display: none;
|
||
}
|
||
|
||
.wplace-btn-overlay.active {
|
||
background: linear-gradient(135deg, #29b6f6 0%, #8e2de2 100%);
|
||
box-shadow: 0 0 15px #8e2de2;
|
||
}
|
||
|
||
.wplace-stats {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(255,255,255,0.03)" : theme.secondary};
|
||
padding: ${CONFIG.currentTheme === "Neon Retro" ? "12px" : "8px"};
|
||
border: ${CONFIG.currentTheme === "Neon Retro" ? `2px solid ${theme.text}` : "1px solid rgba(255,255,255,0.1)"};
|
||
border-radius: ${theme.borderRadius};
|
||
margin-bottom: ${CONFIG.currentTheme === "Neon Retro" ? "15px" : "8px"};
|
||
${CONFIG.currentTheme === "Neon Retro" ? "box-shadow: inset 0 0 10px rgba(0, 255, 65, 0.1);" : ""}
|
||
}
|
||
|
||
.wplace-stat-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: ${CONFIG.currentTheme === "Neon Retro" ? "6px 0" : "4px 0"};
|
||
font-size: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "11px"};
|
||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||
${CONFIG.currentTheme === "Neon Retro" ? "text-transform: uppercase; letter-spacing: 1px;" : ""}
|
||
}
|
||
.wplace-stat-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.wplace-stat-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
opacity: 0.9;
|
||
font-size: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "10px"};
|
||
}
|
||
.wplace-stat-value {
|
||
font-weight: 600;
|
||
color: ${theme.highlight};
|
||
}
|
||
|
||
.wplace-colors-section {
|
||
margin-top: 10px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid rgba(255,255,255,0.05);
|
||
}
|
||
|
||
.wplace-stat-colors-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(16px, 1fr));
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
padding: 4px;
|
||
background: rgba(0,0,0,0.2);
|
||
border-radius: 4px;
|
||
max-height: 80px; /* Limit height and allow scrolling */
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.wplace-stat-color-swatch {
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 3px;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
box-shadow: inset 0 0 2px rgba(0,0,0,0.5);
|
||
}
|
||
|
||
.wplace-progress {
|
||
width: 100%;
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(0,0,0,0.3)" : theme.secondary};
|
||
border: ${CONFIG.currentTheme === "Neon Retro" ? `2px solid ${theme.text}` : "1px solid rgba(255,255,255,0.1)"};
|
||
border-radius: ${theme.borderRadius};
|
||
margin: ${CONFIG.currentTheme === "Neon Retro" ? "10px 0" : "8px 0"};
|
||
overflow: hidden;
|
||
height: ${CONFIG.currentTheme === "Neon Retro" ? "16px" : "6px"};
|
||
position: relative;
|
||
}
|
||
|
||
${CONFIG.currentTheme === "Neon Retro"
|
||
? `
|
||
.wplace-progress::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background:
|
||
repeating-linear-gradient(
|
||
45deg,
|
||
transparent,
|
||
transparent 2px,
|
||
rgba(0, 255, 65, 0.1) 2px,
|
||
rgba(0, 255, 65, 0.1) 4px
|
||
);
|
||
pointer-events: none;
|
||
}`
|
||
: ""
|
||
}
|
||
|
||
.wplace-progress-bar {
|
||
height: ${CONFIG.currentTheme === "Neon Retro" ? "100%" : "6px"};
|
||
background: ${CONFIG.currentTheme === "Classic Autobot"
|
||
? `linear-gradient(135deg, ${theme.highlight} 0%, #9370db 100%)`
|
||
: `linear-gradient(90deg, ${theme.success}, ${theme.neon})`
|
||
};
|
||
transition: width ${CONFIG.currentTheme === "Neon Retro" ? "0.3s" : "0.5s"} ease;
|
||
position: relative;
|
||
${CONFIG.currentTheme === "Neon Retro" ? `box-shadow: 0 0 10px ${theme.success};` : ""}
|
||
}
|
||
|
||
${CONFIG.currentTheme === "Classic Autobot"
|
||
? `
|
||
.wplace-progress-bar::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||
animation: shimmer 2s infinite;
|
||
}`
|
||
: `
|
||
.wplace-progress-bar::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 4px;
|
||
height: 100%;
|
||
background: ${theme.text};
|
||
animation: pixelBlink 1s infinite;
|
||
}`
|
||
}
|
||
|
||
.wplace-status {
|
||
padding: ${CONFIG.currentTheme === "Neon Retro" ? "10px" : "6px"};
|
||
border: ${CONFIG.currentTheme === "Neon Retro" ? "2px solid" : "1px solid"};
|
||
border-radius: ${theme.borderRadius};
|
||
text-align: center;
|
||
font-size: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "11px"};
|
||
${CONFIG.currentTheme === "Neon Retro" ? "text-transform: uppercase; letter-spacing: 1px;" : ""}
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.status-default {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(255,255,255,0.1)" : theme.accent};
|
||
border-color: ${theme.text};
|
||
color: ${theme.text};
|
||
}
|
||
.status-success {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(0, 255, 0, 0.1)" : theme.success};
|
||
border-color: ${theme.success};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? theme.success : theme.primary};
|
||
box-shadow: 0 0 15px ${theme.success};
|
||
}
|
||
.status-error {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(255, 0, 0, 0.1)" : theme.error};
|
||
border-color: ${theme.error};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? theme.error : theme.text};
|
||
box-shadow: 0 0 15px ${theme.error};
|
||
${theme.animations.pixelBlink ? "animation: pixelBlink 0.5s infinite;" : ""}
|
||
}
|
||
.status-warning {
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "rgba(255, 165, 0, 0.1)" : theme.warning};
|
||
border-color: ${theme.warning};
|
||
color: ${CONFIG.currentTheme === "Classic Autobot" ? "orange" : theme.primary};
|
||
box-shadow: 0 0 15px ${theme.warning};
|
||
}
|
||
|
||
.resize-container {
|
||
display: none;
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: ${theme.primary};
|
||
padding: 20px;
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.text};
|
||
border-radius: ${theme.borderRadius};
|
||
z-index: 10000;
|
||
box-shadow: ${CONFIG.currentTheme === "Classic Autobot" ? "0 0 20px rgba(0,0,0,0.5)" : "0 0 30px rgba(0, 255, 65, 0.5)"
|
||
};
|
||
width: 90%;
|
||
max-width: 700px;
|
||
max-height: 90%;
|
||
overflow: auto;
|
||
font-family: ${theme.fontFamily};
|
||
}
|
||
|
||
.resize-preview-wrapper {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
border: 1px solid ${theme.accent};
|
||
background: rgba(0,0,0,0.2);
|
||
margin: 15px 0;
|
||
height: 300px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.resize-canvas-stack { position: relative; transform-origin: center center; display: inline-block; }
|
||
.resize-base-canvas, .resize-mask-canvas {
|
||
position: absolute; left: 0; top: 0;
|
||
image-rendering: pixelated;
|
||
image-rendering: -moz-crisp-edges;
|
||
image-rendering: crisp-edges;
|
||
}
|
||
.resize-mask-canvas { pointer-events: auto; }
|
||
.resize-tools { display:flex; gap:8px; align-items:center; margin-top:8px; font-size:12px; }
|
||
.resize-tools button { padding:6px 10px; border-radius:6px; border:1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.06); color:#fff; cursor:pointer; }
|
||
.wplace-btn.active,
|
||
.wplace-btn[aria-pressed="true"] {
|
||
background: ${theme.highlight} !important;
|
||
color: ${theme.primary} !important;
|
||
border-color: ${theme.text} !important;
|
||
box-shadow: 0 0 8px rgba(0,0,0,0.25) inset, 0 0 6px rgba(0,0,0,0.2) !important;
|
||
}
|
||
.wplace-btn.active i,
|
||
.wplace-btn[aria-pressed="true"] i { filter: drop-shadow(0 0 3px ${theme.primary}); }
|
||
.mask-mode-group .wplace-btn.active,
|
||
.mask-mode-group .wplace-btn[aria-pressed="true"] {
|
||
background: ${theme.highlight};
|
||
color: ${theme.primary};
|
||
border-color: ${theme.text};
|
||
box-shadow: 0 0 8px rgba(0,0,0,0.25) inset, 0 0 6px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.resize-controls {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 15px;
|
||
align-items: center;
|
||
}
|
||
|
||
.resize-controls label {
|
||
font-size: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "12px"};
|
||
${CONFIG.currentTheme === "Neon Retro" ? "text-transform: uppercase; letter-spacing: 1px;" : ""}
|
||
color: ${theme.text};
|
||
}
|
||
|
||
.resize-slider {
|
||
width: 100%;
|
||
height: ${CONFIG.currentTheme === "Neon Retro" ? "8px" : "4px"};
|
||
background: ${CONFIG.currentTheme === "Classic Autobot" ? "#ccc" : theme.secondary};
|
||
border: ${CONFIG.currentTheme === "Neon Retro" ? `2px solid ${theme.text}` : "none"};
|
||
border-radius: ${theme.borderRadius};
|
||
outline: none;
|
||
-webkit-appearance: none;
|
||
}
|
||
|
||
${CONFIG.currentTheme === "Neon Retro"
|
||
? `
|
||
.resize-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 16px;
|
||
height: 16px;
|
||
background: ${theme.highlight};
|
||
border: 2px solid ${theme.text};
|
||
border-radius: 0;
|
||
cursor: pointer;
|
||
box-shadow: 0 0 5px ${theme.highlight};
|
||
}
|
||
|
||
.resize-slider::-moz-range-thumb {
|
||
width: 16px;
|
||
height: 16px;
|
||
background: ${theme.highlight};
|
||
border: 2px solid ${theme.text};
|
||
border-radius: 0;
|
||
cursor: pointer;
|
||
box-shadow: 0 0 5px ${theme.highlight};
|
||
}`
|
||
: ""
|
||
}
|
||
|
||
.resize-zoom-controls {
|
||
grid-column: 1 / -1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.resize-buttons {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: center;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.resize-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
z-index: 9999;
|
||
display: none;
|
||
}
|
||
.wplace-color-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||
gap: 10px;
|
||
padding-top: 8px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
.wplace-color-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.wplace-color-item-name {
|
||
font-size: 9px;
|
||
color: #ccc;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
width: 100%;
|
||
}
|
||
.wplace-color-swatch {
|
||
width: 22px;
|
||
height: 22px;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: transform 0.1s ease, box-shadow 0.2s ease;
|
||
position: relative;
|
||
margin: 0 auto;
|
||
}
|
||
.wplace-color-swatch.unavailable {
|
||
border-color: #666;
|
||
border-style: dashed;
|
||
cursor: not-allowed;
|
||
}
|
||
.wplace-color-swatch:hover {
|
||
transform: scale(1.1);
|
||
z-index: 1;
|
||
}
|
||
.wplace-color-swatch:not(.active) {
|
||
opacity: 0.3;
|
||
filter: grayscale(80%);
|
||
}
|
||
.wplace-color-swatch.unavailable:not(.active) {
|
||
opacity: 0.2;
|
||
filter: grayscale(90%);
|
||
}
|
||
.wplace-color-swatch.active::after {
|
||
content: '✔';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
text-shadow: 0 0 3px black;
|
||
}
|
||
.wplace-color-divider {
|
||
border: none;
|
||
height: 1px;
|
||
background: rgba(255,255,255,0.1);
|
||
margin: 8px 0;
|
||
}
|
||
|
||
.wplace-cooldown-control {
|
||
margin-top: 8px;
|
||
}
|
||
.wplace-cooldown-control label {
|
||
font-size: 11px;
|
||
margin-bottom: 4px;
|
||
display: block;
|
||
}
|
||
.wplace-slider-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.wplace-slider {
|
||
flex: 1;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
height: 4px;
|
||
background: #444;
|
||
border-radius: 2px;
|
||
outline: none;
|
||
}
|
||
.wplace-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: ${theme.highlight};
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Switch toggle for auto-swap */
|
||
.wplace-switch { position: relative; display: inline-block; width: 34px; height: 20px; }
|
||
.wplace-switch input { opacity: 0; width: 0; height: 0; }
|
||
.wplace-slider-round { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: ${theme.secondary}; border: 1px solid ${theme.accent}; transition: .4s; border-radius: 20px; }
|
||
.wplace-slider-round:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: ${theme.text}; transition: .4s; border-radius: 50%; }
|
||
input:checked + .wplace-slider-round { background-color: ${theme.success}; }
|
||
input:checked + .wplace-slider-round:before { transform: translateX(14px); }
|
||
|
||
/* Accounts list */
|
||
.accounts-list-container { max-height: 280px; overflow-y: auto; padding-right: 5px; display: flex; flex-direction: column; gap: 8px; }
|
||
.wplace-account-item { padding: 8px; background: ${CONFIG.currentTheme === "Classic Autobot" ? 'rgba(255,255,255,0.03)' : theme.secondary}; border: 1px solid ${theme.accent}; border-radius: ${theme.borderRadius}; display: grid; grid-template-columns: 1fr auto auto; gap: 10px; align-items: center; font-size: 11px; transition: all 0.2s ease; }
|
||
.wplace-account-item.is-current { border-left: 3px solid ${theme.highlight}; background: ${CONFIG.currentTheme === "Classic Autobot" ? 'rgba(119, 92, 227, 0.2)' : theme.accent}; }
|
||
|
||
.wplace-account-details { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.wplace-account-name { font-weight: 600; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex-grow: 1; display: flex; align-items: center; }
|
||
.wplace-account-id { opacity: 0.7; font-family: monospace; font-size: 10px; }
|
||
|
||
.wplace-account-stats { display: flex; gap: 8px; align-items: center; white-space: nowrap; }
|
||
.wplace-account-stats span { display: flex; align-items: center; gap: 5px; }
|
||
.wplace-account-stats i { color: ${theme.highlight}; }
|
||
|
||
.wplace-account-controls { display: flex; gap: 6px; }
|
||
.wplace-account-btn { background: transparent; border: none; color: ${theme.text}; opacity: 0.6; cursor: pointer; padding: 4px; font-size: 11px; border-radius: 4px; transition: all 0.2s ease; line-height: 1; }
|
||
.wplace-account-btn:hover { opacity: 1; background: rgba(255,255,255,0.1); }
|
||
.wplace-account-btn.delete-btn:hover { color: ${theme.error}; }
|
||
|
||
|
||
${CONFIG.currentTheme === "Neon Retro"
|
||
? `
|
||
input[type="checkbox"] {
|
||
-webkit-appearance: none;
|
||
-moz-appearance: none;
|
||
appearance: none;
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid ${theme.text};
|
||
background: ${theme.secondary};
|
||
margin-right: 8px;
|
||
position: relative;
|
||
cursor: pointer;
|
||
}
|
||
|
||
input[type="checkbox"]:checked {
|
||
background: ${theme.success};
|
||
}
|
||
|
||
input[type="checkbox"]:checked::after {
|
||
content: '✓';
|
||
position: absolute;
|
||
top: -2px;
|
||
left: 1px;
|
||
color: ${theme.primary};
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.fas, .fa {
|
||
filter: drop-shadow(0 0 3px currentColor);
|
||
}
|
||
|
||
.wplace-speed-control {
|
||
margin-top: 12px;
|
||
padding: 12px;
|
||
background: ${theme.secondary};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.accent};
|
||
border-radius: ${theme.borderRadius};
|
||
backdrop-filter: ${theme.backdropFilter};
|
||
}
|
||
|
||
.wplace-speed-label {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
color: ${theme.text};
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.wplace-speed-label i {
|
||
margin-right: 6px;
|
||
color: ${theme.highlight};
|
||
}
|
||
|
||
.wplace-speed-slider-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.wplace-speed-slider {
|
||
flex: 1;
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background: ${theme.primary};
|
||
outline: none;
|
||
cursor: pointer;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
|
||
.wplace-speed-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: ${theme.highlight};
|
||
cursor: pointer;
|
||
border: 2px solid ${theme.text};
|
||
box-shadow: ${theme.boxShadow};
|
||
}
|
||
|
||
.wplace-speed-slider::-moz-range-thumb {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: ${theme.highlight};
|
||
cursor: pointer;
|
||
border: 2px solid ${theme.text};
|
||
box-shadow: ${theme.boxShadow};
|
||
}
|
||
|
||
.wplace-speed-display {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
min-width: 90px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
#speedValue {
|
||
color: ${theme.highlight};
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.wplace-speed-unit {
|
||
color: ${theme.text};
|
||
font-size: 11px;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
#wplace-settings-container {
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 10001;
|
||
min-width: 400px;
|
||
max-width: 500px;
|
||
background: ${theme.primary};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.accent};
|
||
border-radius: ${theme.borderRadius};
|
||
box-shadow: ${theme.boxShadow};
|
||
backdrop-filter: ${theme.backdropFilter};
|
||
}
|
||
|
||
.wplace-settings {
|
||
padding: 16px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.wplace-setting-section {
|
||
margin-bottom: 20px;
|
||
padding: 12px;
|
||
background: ${theme.secondary};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.accent};
|
||
border-radius: ${theme.borderRadius};
|
||
}
|
||
|
||
.wplace-setting-title {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
color: ${theme.text};
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.wplace-setting-title i {
|
||
margin-right: 8px;
|
||
color: ${theme.highlight};
|
||
}
|
||
|
||
.wplace-setting-content {
|
||
color: ${theme.text};
|
||
}
|
||
|
||
.wplace-section {
|
||
margin-bottom: 20px;
|
||
padding: 15px;
|
||
background: ${theme.secondary};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.accent};
|
||
border-radius: ${theme.borderRadius};
|
||
}
|
||
|
||
.wplace-section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
color: ${theme.text};
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.wplace-section-title i {
|
||
margin-right: 8px;
|
||
color: ${theme.highlight};
|
||
}
|
||
|
||
.wplace-speed-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.wplace-slider {
|
||
flex: 1;
|
||
height: 6px;
|
||
background: ${theme.accent};
|
||
border-radius: 3px;
|
||
outline: none;
|
||
-webkit-appearance: none;
|
||
}
|
||
|
||
.wplace-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 18px;
|
||
height: 18px;
|
||
background: ${theme.highlight};
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
border: 2px solid ${theme.primary};
|
||
}
|
||
|
||
.wplace-speed-display {
|
||
background: ${theme.accent};
|
||
padding: 5px 10px;
|
||
border-radius: 4px;
|
||
color: ${theme.text};
|
||
font-weight: 600;
|
||
min-width: 80px;
|
||
text-align: center;
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.highlight};
|
||
}
|
||
|
||
.wplace-select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
background: ${theme.secondary};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.accent};
|
||
border-radius: ${theme.borderRadius};
|
||
color: ${theme.text};
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.wplace-select:focus {
|
||
outline: none;
|
||
border-color: ${theme.highlight};
|
||
}
|
||
|
||
.wplace-description {
|
||
color: ${theme.text};
|
||
font-size: 12px;
|
||
opacity: 0.8;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.wplace-theme-custom {
|
||
margin-top: 15px;
|
||
padding: 15px;
|
||
background: ${theme.accent};
|
||
border-radius: ${theme.borderRadius};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.highlight};
|
||
}
|
||
|
||
.wplace-custom-group {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.wplace-custom-label {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
color: ${theme.text};
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.wplace-custom-label i {
|
||
margin-right: 8px;
|
||
color: ${theme.highlight};
|
||
width: 16px;
|
||
}
|
||
|
||
.wplace-color-input-group {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.wplace-color-input {
|
||
width: 50px;
|
||
height: 30px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
background: transparent;
|
||
}
|
||
|
||
.wplace-color-text {
|
||
flex: 1;
|
||
padding: 6px 10px;
|
||
background: ${theme.secondary};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.accent};
|
||
border-radius: 4px;
|
||
color: ${theme.text};
|
||
font-size: 12px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.wplace-animation-controls {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.wplace-checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: ${theme.text};
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.wplace-checkbox-label input[type="checkbox"] {
|
||
accent-color: ${theme.highlight};
|
||
}
|
||
|
||
.wplace-slider-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.wplace-slider-container .wplace-slider {
|
||
flex: 1;
|
||
}
|
||
|
||
.wplace-slider-container span {
|
||
color: ${theme.text};
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
min-width: 40px;
|
||
}
|
||
|
||
.wplace-custom-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 20px;
|
||
border-top: 1px solid ${theme.accent};
|
||
padding-top: 15px;
|
||
}
|
||
|
||
.wplace-btn-secondary {
|
||
background: ${theme.accent};
|
||
color: ${theme.text};
|
||
border: ${theme.borderWidth} ${theme.borderStyle} ${theme.highlight};
|
||
}
|
||
|
||
.wplace-btn-secondary:hover {
|
||
background: ${theme.secondary};
|
||
}`
|
||
: ""
|
||
}
|
||
`
|
||
document.head.appendChild(style)
|
||
|
||
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="Show Stats">
|
||
<i class="fas fa-chart-bar"></i>
|
||
</button>
|
||
<button id="compactBtn" class="wplace-header-btn" title="Compact Mode">
|
||
<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="🔄 Waiting for initial setup to complete...">
|
||
<i class="fas fa-upload"></i>
|
||
<span>${Utils.t("uploadImage")}</span>
|
||
</button>
|
||
<button id="resizeBtn" class="wplace-btn wplace-btn-primary" disabled>
|
||
<i class="fas fa-expand"></i>
|
||
<span>${Utils.t("resizeImage")}</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-slider-container">
|
||
<input type="range" id="cooldownSlider" class="wplace-slider" min="1" max="1" value="${state.cooldownChargeThreshold}">
|
||
<span id="cooldownValue" style="font-weight:bold; min-width: 20px; text-align: center;">${state.cooldownChargeThreshold}</span>
|
||
</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="🔄 Waiting for token generator to initialize...">
|
||
<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="🔄 Waiting for token generator to initialize...">
|
||
<i class="fas fa-upload"></i>
|
||
<span>${Utils.t("loadFromFile")}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
|
||
// Stats Window - Separate UI
|
||
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>Painting Stats</span>
|
||
</div>
|
||
<div class="wplace-header-controls">
|
||
<button id="refreshAllAccountsBtn" class="wplace-header-btn" title="Refresh All Accounts">
|
||
<i class="fas fa-users-cog"></i>
|
||
</button>
|
||
<button id="refreshChargesBtn" class="wplace-header-btn" title="Refresh Charges">
|
||
<i class="fas fa-sync"></i>
|
||
</button>
|
||
<button id="closeStatsBtn" class="wplace-header-btn" title="Close Stats">
|
||
<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="all-accounts-section">
|
||
<div class="wplace-section-title">
|
||
<i class="fas fa-users"></i>
|
||
<span>All Accounts</span>
|
||
</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>
|
||
`
|
||
|
||
// 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.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: ${themeBackground};
|
||
border: ${theme.borderWidth || '1px'} ${theme.borderStyle || 'solid'} ${theme.accent || 'rgba(255,255,255,0.1)'};
|
||
border-radius: ${theme.borderRadius || '16px'};
|
||
padding: 0;
|
||
z-index: 10002;
|
||
display: none;
|
||
min-width: 420px;
|
||
max-width: 480px;
|
||
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: settingsSlideIn 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'};
|
||
` : ''}
|
||
`
|
||
|
||
settingsContainer.innerHTML = `
|
||
<div class="wplace-settings-header" style="
|
||
background: ${theme.accent ? `${theme.accent}33` : 'rgba(255,255,255,0.1)'};
|
||
padding: 20px;
|
||
border-bottom: 1px solid ${theme.accent || 'rgba(255,255,255,0.1)'};
|
||
cursor: move;
|
||
${theme.animations?.scanline ? `
|
||
position: relative;
|
||
overflow: hidden;
|
||
` : ''}
|
||
">
|
||
${theme.animations?.scanline ? `
|
||
<div style="
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, transparent, ${theme.neon || '#00ffff'}, transparent);
|
||
animation: scanline 2s linear infinite;
|
||
"></div>
|
||
` : ''}
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<h3 style="
|
||
margin: 0;
|
||
color: ${theme.text || 'white'};
|
||
font-size: 20px;
|
||
font-weight: 300;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
${theme.animations?.glow ? `
|
||
text-shadow: 0 0 10px ${theme.highlight || theme.neon || '#00ffff'};
|
||
` : ''}
|
||
">
|
||
<i class="fas fa-cog" style="
|
||
font-size: 18px;
|
||
animation: spin 2s linear infinite;
|
||
color: ${theme.highlight || theme.neon || '#00ffff'};
|
||
"></i>
|
||
${Utils.t("settings")}
|
||
</h3>
|
||
<button id="closeSettingsBtn" style="
|
||
background: ${theme.accent ? `${theme.accent}66` : 'rgba(255,255,255,0.1)'};
|
||
color: ${theme.text || 'white'};
|
||
border: 1px solid ${theme.accent || 'rgba(255,255,255,0.2)'};
|
||
border-radius: ${theme.borderRadius === '0' ? '0' : '50%'};
|
||
width: 32px;
|
||
height: 32px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s ease;
|
||
font-size: 14px;
|
||
font-weight: 300;
|
||
${theme.animations?.glow ? `
|
||
box-shadow: 0 0 10px ${theme.error || '#ff0000'}33;
|
||
` : ''}
|
||
" onmouseover="
|
||
this.style.background='${theme.error || '#ff0000'}66';
|
||
this.style.transform='scale(1.1)';
|
||
${theme.animations?.glow ? `this.style.boxShadow='0 0 20px ${theme.error || '#ff0000'}';` : ''}
|
||
" onmouseout="
|
||
this.style.background='${theme.accent ? `${theme.accent}66` : 'rgba(255,255,255,0.1)'}';
|
||
this.style.transform='scale(1)';
|
||
${theme.animations?.glow ? `this.style.boxShadow='0 0 10px ${theme.error || '#ff0000'}33';` : ''}
|
||
">✕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="padding: 25px; max-height: 70vh; overflow-y: auto;">
|
||
|
||
<!-- Token Source Selection -->
|
||
<div style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 12px; color: white; font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-key" style="color: #4facfe; font-size: 16px;"></i>
|
||
Token Source
|
||
</label>
|
||
<div style="background: rgba(255,255,255,0.1); border-radius: 12px; padding: 18px; border: 1px solid rgba(255,255,255,0.1);">
|
||
<select id="tokenSourceSelect" style="
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: rgba(255,255,255,0.15);
|
||
color: white;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
font-family: inherit;
|
||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
||
">
|
||
<option value="generator" ${state.tokenSource === 'generator' ? 'selected' : ''} style="background: #2d3748; color: white; padding: 10px;">🤖 Automatic Token Generator (Recommended)</option>
|
||
<option value="hybrid" ${state.tokenSource === 'hybrid' ? 'selected' : ''} style="background: #2d3748; color: white; padding: 10px;">🔄 Generator + Auto Fallback</option>
|
||
<option value="manual" ${state.tokenSource === 'manual' ? 'selected' : ''} style="background: #2d3748; color: white; padding: 10px;">🎯 Manual Pixel Placement</option>
|
||
</select>
|
||
<p style="font-size: 12px; color: rgba(255,255,255,0.7); margin: 8px 0 0 0;">
|
||
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 style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 12px; color: white; font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-robot" style="color: #4facfe; font-size: 16px;"></i>
|
||
${Utils.t("automation")}
|
||
</label>
|
||
<!-- Token generator is always enabled - settings moved to Token Source above -->
|
||
</div>
|
||
|
||
<!-- Overlay Settings Section -->
|
||
<div style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 12px; color: ${theme.text || 'white'}; font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-eye" style="color: ${theme.highlight || '#48dbfb'}; font-size: 16px;"></i>
|
||
Overlay Settings
|
||
</label>
|
||
<div 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 style="margin-bottom: 15px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||
<span style="font-weight: 500; font-size: 13px; color: ${theme.text || 'white'};">Overlay Opacity</span>
|
||
<div id="overlayOpacityValue" style="
|
||
min-width: 40px;
|
||
text-align: center;
|
||
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}" style="
|
||
width: 100%;
|
||
-webkit-appearance: none;
|
||
height: 8px;
|
||
background: linear-gradient(to right, ${theme.highlight || '#48dbfb'} 0%, ${theme.purple || theme.neon || '#d3a4ff'} 100%);
|
||
border-radius: ${theme.borderRadius === '0' ? '0' : '4px'};
|
||
outline: none;
|
||
cursor: pointer;
|
||
">
|
||
</div>
|
||
<!-- Blue Marble Toggle -->
|
||
<label for="enableBlueMarbleToggle" style="display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
|
||
<div>
|
||
<span style="font-weight: 500; color: ${theme.text || 'white'};">Blue Marble Effect</span>
|
||
<p style="font-size: 12px; color: ${theme.text ? `${theme.text}BB` : 'rgba(255,255,255,0.7)'}; margin: 4px 0 0 0;">Renders a dithered "shredded" overlay.</p>
|
||
</div>
|
||
<input type="checkbox" id="enableBlueMarbleToggle" ${state.blueMarbleEnabled ? 'checked' : ''} style="
|
||
cursor: pointer;
|
||
width: 20px;
|
||
height: 20px;
|
||
accent-color: ${theme.highlight || '#48dbfb'};
|
||
"/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Speed Control Section -->
|
||
<div style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 12px; color: white; font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-tachometer-alt" style="color: #4facfe; font-size: 16px;"></i>
|
||
${Utils.t("paintingSpeed")}
|
||
</label>
|
||
|
||
<!-- Batch Mode Selection -->
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 8px; color: rgba(255,255,255,0.9); font-weight: 500; font-size: 14px;">
|
||
<i class="fas fa-dice" style="color: #f093fb; margin-right: 6px;"></i>
|
||
Batch Mode
|
||
</label>
|
||
<select id="batchModeSelect" style="
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
background: rgba(255,255,255,0.15);
|
||
color: white;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
">
|
||
<option value="normal" style="background: #2d3748; color: white;">📦 Normal (Fixed Size)</option>
|
||
<option value="random" style="background: #2d3748; color: white;">🎲 Random (Range)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Normal Mode: Fixed Size Slider -->
|
||
<div id="normalBatchControls" style="background: rgba(255,255,255,0.1); border-radius: 12px; padding: 18px; border: 1px solid rgba(255,255,255,0.1); margin-bottom: 15px;">
|
||
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 10px;">
|
||
<input type="range" id="speedSlider" min="${CONFIG.PAINTING_SPEED.MIN}" max="${CONFIG.PAINTING_SPEED.MAX}" value="${CONFIG.PAINTING_SPEED.DEFAULT}"
|
||
style="
|
||
flex: 1;
|
||
height: 8px;
|
||
background: linear-gradient(to right, #4facfe 0%, #00f2fe 100%);
|
||
border-radius: 4px;
|
||
outline: none;
|
||
-webkit-appearance: none;
|
||
cursor: pointer;
|
||
">
|
||
<div id="speedValue" style="
|
||
min-width: 100px;
|
||
text-align: center;
|
||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
color: white;
|
||
font-weight: bold;
|
||
font-size: 13px;
|
||
box-shadow: 0 3px 10px rgba(79, 172, 254, 0.3);
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
">${CONFIG.PAINTING_SPEED.DEFAULT} (batch size)</div>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; color: rgba(255,255,255,0.7); font-size: 11px; margin-top: 8px;">
|
||
<span><i class="fas fa-turtle"></i> ${CONFIG.PAINTING_SPEED.MIN}</span>
|
||
<span><i class="fas fa-rabbit"></i> ${CONFIG.PAINTING_SPEED.MAX}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Random Mode: Range Controls -->
|
||
<div id="randomBatchControls" style="display: none; background: rgba(255,255,255,0.1); border-radius: 12px; padding: 18px; border: 1px solid rgba(255,255,255,0.1); margin-bottom: 15px;">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||
<div>
|
||
<label style="display: block; color: rgba(255,255,255,0.8); font-size: 12px; margin-bottom: 8px;">
|
||
<i class="fas fa-arrow-down" style="color: #4facfe; margin-right: 4px;"></i>
|
||
Minimum Batch Size
|
||
</label>
|
||
<input type="number" id="randomBatchMin" min="1" max="1000" value="${CONFIG.RANDOM_BATCH_RANGE.MIN}" style="
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
background: rgba(255,255,255,0.1);
|
||
color: white;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
outline: none;
|
||
">
|
||
</div>
|
||
<div>
|
||
<label style="display: block; color: rgba(255,255,255,0.8); font-size: 12px; margin-bottom: 8px;">
|
||
<i class="fas fa-arrow-up" style="color: #00f2fe; margin-right: 4px;"></i>
|
||
Maximum Batch Size
|
||
</label>
|
||
<input type="number" id="randomBatchMax" min="1" max="1000" value="${CONFIG.RANDOM_BATCH_RANGE.MAX}" style="
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
background: rgba(255,255,255,0.1);
|
||
color: white;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
outline: none;
|
||
">
|
||
</div>
|
||
</div>
|
||
<p style="font-size: 11px; color: rgba(255,255,255,0.6); margin: 8px 0 0 0; text-align: center;">
|
||
🎲 Random batch size between min and max values
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Speed Control Toggle -->
|
||
<label style="display: flex; align-items: center; gap: 8px; color: white;">
|
||
<input type="checkbox" id="enableSpeedToggle" ${CONFIG.PAINTING_SPEED_ENABLED ? 'checked' : ''} style="cursor: pointer;"/>
|
||
<span>Enable painting speed limit (batch size control)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Notifications Section -->
|
||
<div style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 12px; color: white; font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-bell" style="color: #ffd166; font-size: 16px;"></i>
|
||
Desktop Notifications
|
||
</label>
|
||
<div style="background: rgba(255,255,255,0.1); border-radius: 12px; padding: 18px; border: 1px solid rgba(255,255,255,0.1); display:flex; flex-direction:column; gap:10px;">
|
||
<label style="display:flex; align-items:center; justify-content:space-between;">
|
||
<span>Enable notifications</span>
|
||
<input type="checkbox" id="notifEnabledToggle" ${state.notificationsEnabled ? 'checked' : ''} style="width:18px; height:18px; cursor:pointer;" />
|
||
</label>
|
||
<label style="display:flex; align-items:center; justify-content:space-between;">
|
||
<span>Notify when charges reach threshold</span>
|
||
<input type="checkbox" id="notifOnChargesToggle" ${state.notifyOnChargesReached ? 'checked' : ''} style="width:18px; height:18px; cursor:pointer;" />
|
||
</label>
|
||
<label style="display:flex; align-items:center; justify-content:space-between;">
|
||
<span>Only when tab is not focused</span>
|
||
<input type="checkbox" id="notifOnlyUnfocusedToggle" ${state.notifyOnlyWhenUnfocused ? 'checked' : ''} style="width:18px; height:18px; cursor:pointer;" />
|
||
</label>
|
||
<div style="display:flex; align-items:center; gap:10px;">
|
||
<span>Repeat every</span>
|
||
<input type="number" id="notifIntervalInput" min="1" max="60" value="${state.notificationIntervalMinutes}" style="width:70px; padding:6px 8px; border-radius:6px; border:1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.08); color:#fff;" />
|
||
<span>minute(s)</span>
|
||
</div>
|
||
<div style="display:flex; gap:10px;">
|
||
<button id="notifRequestPermBtn" class="wplace-btn wplace-btn-secondary" style="flex:1;"><i class="fas fa-unlock"></i><span>Grant Permission</span></button>
|
||
<button id="notifTestBtn" class="wplace-btn" style="flex:1;"><i class="fas fa-bell"></i><span>Test</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Theme Selection Section -->
|
||
<div style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 12px; color: white; font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-palette" style="color: #f093fb; font-size: 16px;"></i>
|
||
${Utils.t("themeSettings")}
|
||
</label>
|
||
<div style="background: rgba(255,255,255,0.1); border-radius: 12px; padding: 18px; border: 1px solid rgba(255,255,255,0.1);">
|
||
<select id="themeSelect" style="
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: rgba(255,255,255,0.15);
|
||
color: white;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
font-family: inherit;
|
||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
||
">
|
||
${Object.keys(CONFIG.THEMES).map(themeName =>
|
||
`<option value="${themeName}" ${CONFIG.currentTheme === themeName ? 'selected' : ''} style="background: #2d3748; color: white; padding: 10px;">${themeName}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Language Selection Section -->
|
||
<div style="margin-bottom: 25px;">
|
||
<label style="display: block; margin-bottom: 12px; color: white; font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px;">
|
||
<i class="fas fa-globe" style="color: #ffeaa7; font-size: 16px;"></i>
|
||
${Utils.t("language")}
|
||
</label>
|
||
<div style="background: rgba(255,255,255,0.1); border-radius: 12px; padding: 18px; border: 1px solid rgba(255,255,255,0.1);">
|
||
<select id="languageSelect" style="
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: rgba(255,255,255,0.15);
|
||
color: white;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
font-family: inherit;
|
||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
||
">
|
||
<option value="vi" ${state.language === 'vi' ? 'selected' : ''} style="background: #2d3748; color: white;">🇻🇳 Tiếng Việt</option>
|
||
<option value="id" ${state.language === 'id' ? 'selected' : ''} style="background: #2d3748; color: white;">🇮🇩 Bahasa Indonesia</option>
|
||
<option value="ru" ${state.language === 'ru' ? 'selected' : ''} style="background: #2d3748; color: white;">🇷🇺 Русский</option>
|
||
<option value="en" ${state.language === 'en' ? 'selected' : ''} style="background: #2d3748; color: white;">🇺🇸 English</option>
|
||
<option value="pt" ${state.language === 'pt' ? 'selected' : ''} style="background: #2d3748; color: white;">🇧🇷 Português</option>
|
||
<option value="fr" ${state.language === 'fr' ? 'selected' : ''} style="background: #2d3748; color: white;">🇫🇷 Français</option>
|
||
<option value="tr" ${state.language === 'tr' ? 'selected' : ''} style="background: #2d3748; color: white;">🇹🇷 Türkçe</option>
|
||
<option value="zh" ${state.language === 'zh' ? 'selected' : ''} style="background: #2d3748; color: white;">🇨🇳 简体中文</option>
|
||
<option value="zh-tw" ${state.language === 'zh-tw' ? 'selected' : ''} style="background: #2d3748; color: white;">🇹🇼 繁體中文</option>
|
||
<option value="ja" ${state.language === 'ja' ? 'selected' : ''} style="background: #2d3748; color: white;">🇯🇵 日本語</option>
|
||
<option value="ko" ${state.language === 'ko' ? 'selected' : ''} style="background: #2d3748; color: white;">🇰🇷 한국어</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 20px; margin-top: 10px;">
|
||
<button id="applySettingsBtn" style="
|
||
width: 100%;
|
||
${CONFIG.CSS_CLASSES.BUTTON_PRIMARY}
|
||
">
|
||
<i class="fas fa-check"></i> ${Utils.t("applySettings")}
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<style>
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
@keyframes settingsSlideIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translate(-50%, -50%) scale(0.9);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translate(-50%, -50%) scale(1);
|
||
}
|
||
}
|
||
|
||
@keyframes settingsFadeOut {
|
||
from {
|
||
opacity: 1;
|
||
transform: translate(-50%, -50%) scale(1);
|
||
}
|
||
to {
|
||
opacity: 0;
|
||
transform: translate(-50%, -50%) scale(0.9);
|
||
}
|
||
}
|
||
|
||
#speedSlider::-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, #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, #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;
|
||
}
|
||
</style>
|
||
`
|
||
|
||
const resizeContainer = document.createElement("div")
|
||
resizeContainer.className = "resize-container"
|
||
resizeContainer.innerHTML = `
|
||
<h3 style="margin-top: 0; color: ${theme.text}">${Utils.t("resizeImage")}</h3>
|
||
<div class="resize-controls">
|
||
<label>
|
||
Width: <span id="widthValue">0</span>px
|
||
<input type="range" id="widthSlider" class="resize-slider" min="10" max="500" value="100">
|
||
</label>
|
||
<label>
|
||
Height: <span id="heightValue">0</span>px
|
||
<input type="range" id="heightSlider" class="resize-slider" min="10" max="500" value="100">
|
||
</label>
|
||
<label style="display: flex; align-items: center;">
|
||
<input type="checkbox" id="keepAspect" checked>
|
||
Keep Aspect Ratio
|
||
</label>
|
||
<label style="display: flex; align-items: center;">
|
||
<input type="checkbox" id="paintWhiteToggle" checked>
|
||
Paint White Pixels
|
||
</label>
|
||
<div class="resize-zoom-controls">
|
||
<button id="zoomOutBtn" class="wplace-btn" title="Zoom Out" style="padding:4px 8px;"><i class="fas fa-search-minus"></i></button>
|
||
<input type="range" id="zoomSlider" class="resize-slider" min="0.1" max="20" value="1" step="0.05" style="max-width: 220px;">
|
||
<button id="zoomInBtn" class="wplace-btn" title="Zoom In" style="padding:4px 8px;"><i class="fas fa-search-plus"></i></button>
|
||
<button id="zoomFitBtn" class="wplace-btn" title="Fit to view" style="padding:4px 8px;">Fit</button>
|
||
<button id="zoomActualBtn" class="wplace-btn" title="Actual size (100%)" style="padding:4px 8px;">100%</button>
|
||
<button id="panModeBtn" class="wplace-btn" title="Pan (drag to move view)" style="padding:4px 8px;">
|
||
<i class="fas fa-hand-paper"></i>
|
||
</button>
|
||
<span id="zoomValue" style="margin-left:6px; min-width:48px; text-align:right; opacity:.85; font-size:12px;">100%</span>
|
||
<div id="cameraHelp" style="font-size:11px; opacity:.75; margin-left:auto;">
|
||
Drag to pan • Pinch to zoom • Double‑tap to zoom
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="resize-preview-wrapper">
|
||
<div id="resizePanStage" style="position:relative; width:100%; height:100%; overflow:hidden;">
|
||
<div id="resizeCanvasStack" class="resize-canvas-stack" style="position:absolute; left:0; top:0; transform-origin: top left;">
|
||
<canvas id="resizeCanvas" class="resize-base-canvas"></canvas>
|
||
<canvas id="maskCanvas" class="resize-mask-canvas"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="resize-tools">
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<div>
|
||
<div style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
|
||
<label style="font-size:12px; opacity:.85;">Brush</label>
|
||
<div style="display:flex; align-items:center; gap:6px;">
|
||
<input id="maskBrushSize" type="range" min="1" max="7" step="1" value="1" style="width:120px;">
|
||
<span id="maskBrushSizeValue" style="font-size:12px; opacity:.85; min-width:18px; text-align:center;">1</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
|
||
<label style="font-size:12px; opacity:.85;">Row/col size</label>
|
||
<div style="display:flex; align-items:center; gap:6px;">
|
||
<input id="rowColSize" type="range" min="1" max="7" step="1" value="1" style="width:120px;">
|
||
<span id="rowColSizeValue" style="font-size:12px; opacity:.85; min-width:18px; text-align:center;">1</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; align-items:center; gap:6px;">
|
||
<label style="font-size:12px; opacity:.85;">Mode</label>
|
||
<div class="mask-mode-group" style="display:flex; gap:6px;">
|
||
<button id="maskModeIgnore" class="wplace-btn" style="padding:4px 8px; font-size:12px;">Ignore</button>
|
||
<button id="maskModeUnignore" class="wplace-btn" style="padding:4px 8px; font-size:12px;">Unignore</button>
|
||
<button id="maskModeToggle" class="wplace-btn wplace-btn-primary" style="padding:4px 8px; font-size:12px;">Toggle</button>
|
||
</div>
|
||
</div>
|
||
<button id="clearIgnoredBtn" class="wplace-btn" title="Clear all ignored pixels" style="padding:4px 8px; font-size:12px;">Clear</button>
|
||
<button id="invertMaskBtn" class="wplace-btn" title="Invert mask" style="padding:4px 8px; font-size:12px;">Invert</button>
|
||
<span style="opacity:.8; font-size:12px;">Shift = Row • Alt = Column</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wplace-section" id="color-palette-section" style="margin-top: 15px;">
|
||
<div class="wplace-section-title">
|
||
<i class="fas fa-palette"></i> Color Palette
|
||
</div>
|
||
<div class="wplace-controls">
|
||
<div class="wplace-row single">
|
||
<label style="display: flex; align-items: center; gap: 8px; font-size: 12px;">
|
||
<input type="checkbox" id="showAllColorsToggle" style="cursor: pointer;">
|
||
<span>Show All Colors (including unavailable)</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" id="advanced-color-section" style="margin-top: 15px;">
|
||
<div class="wplace-section-title">
|
||
<i class="fas fa-flask"></i> Advanced Color Matching
|
||
</div>
|
||
<div style="display:flex; flex-direction:column; gap:10px;">
|
||
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px;">
|
||
<span style="font-weight:600;">Algorithm</span>
|
||
<select id="colorAlgorithmSelect" style="padding:6px 8px; border-radius:6px; border:1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color:#fff;">
|
||
<option value="lab" ${state.colorMatchingAlgorithm === 'lab' ? 'selected' : ''}>Perceptual (Lab)</option>
|
||
<option value="legacy" ${state.colorMatchingAlgorithm === 'legacy' ? 'selected' : ''}>Legacy (RGB)</option>
|
||
</select>
|
||
</label>
|
||
<label style="display:flex; align-items:center; justify-content:space-between; font-size:12px;">
|
||
<div style="flex:1;">
|
||
<span style="font-weight:600;">Chroma Penalty</span>
|
||
<div style="margin-top:2px; opacity:0.65;">Preserve vivid colors (Lab only)</div>
|
||
</div>
|
||
<input type="checkbox" id="enableChromaPenaltyToggle" ${state.enableChromaPenalty ? 'checked' : ''} style="width:18px; height:18px; cursor:pointer;" />
|
||
</label>
|
||
<div>
|
||
<div style="display:flex; justify-content:space-between; font-size:11px; margin-bottom:4px;">
|
||
<span>Chroma Weight</span>
|
||
<span id="chromaWeightValue" style="background:rgba(255,255,255,0.08); padding:2px 6px; border-radius:4px;">${state.chromaPenaltyWeight}</span>
|
||
</div>
|
||
<input type="range" id="chromaPenaltyWeightSlider" min="0" max="0.5" step="0.01" value="${state.chromaPenaltyWeight}" style="width:100%;" />
|
||
</div>
|
||
<label style="display:flex; align-items:center; justify-content:space-between; font-size:12px;">
|
||
<div style="flex:1;">
|
||
<span style="font-weight:600;">Enable Dithering</span>
|
||
<div style="margin-top:2px; opacity:0.65;">Floyd–Steinberg error diffusion in preview and applied output</div>
|
||
</div>
|
||
<input type="checkbox" id="enableDitheringToggle" ${state.ditheringEnabled ? 'checked' : ''} style="width:18px; height:18px; cursor:pointer;" />
|
||
</label>
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px;">
|
||
<span style="font-weight:600;">Transparency</span>
|
||
<input type="number" id="transparencyThresholdInput" min="0" max="255" value="${state.customTransparencyThreshold}" style="padding:6px 8px; border-radius:6px; border:1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color:#fff;" />
|
||
</label>
|
||
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px;">
|
||
<span style="font-weight:600;">White Thresh</span>
|
||
<input type="number" id="whiteThresholdInput" min="200" max="255" value="${state.customWhiteThreshold}" style="padding:6px 8px; border-radius:6px; border:1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color:#fff;" />
|
||
</label>
|
||
</div>
|
||
<button id="resetAdvancedColorBtn" class="wplace-btn" style="background:linear-gradient(135deg,#ff6a6a,#ff4757); font-size:11px;">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>Download Preview</span>
|
||
</button>
|
||
<button id="confirmResize" class="wplace-btn wplace-btn-start">
|
||
<i class="fas fa-check"></i>
|
||
<span>Apply</span>
|
||
</button>
|
||
<button id="cancelResize" class="wplace-btn wplace-btn-stop">
|
||
<i class="fas fa-times"></i>
|
||
<span>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)
|
||
|
||
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")
|
||
|
||
// 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 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) {
|
||
console.error("Stats UI elements not found:", {
|
||
statsContainer: !!statsContainer,
|
||
statsArea: !!statsArea,
|
||
closeStatsBtn: !!closeStatsBtn,
|
||
})
|
||
}
|
||
|
||
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 = "Show Stats"
|
||
} else {
|
||
statsContainer.style.display = "block"
|
||
statsBtn.innerHTML = '<i class="fas fa-chart-line"></i>'
|
||
statsBtn.title = "Hide Stats"
|
||
}
|
||
})
|
||
|
||
closeStatsBtn.addEventListener("click", () => {
|
||
statsContainer.style.display = "none"
|
||
statsBtn.innerHTML = '<i class="fas fa-chart-bar"></i>'
|
||
statsBtn.title = "Show Stats"
|
||
})
|
||
|
||
const refreshAllAccountsBtn = statsContainer.querySelector("#refreshAllAccountsBtn");
|
||
if (refreshAllAccountsBtn) {
|
||
refreshAllAccountsBtn.addEventListener('click', fetchAllAccountDetails);
|
||
}
|
||
|
||
const autoSwapToggle = statsContainer.querySelector("#autoSwapToggle");
|
||
if (autoSwapToggle) {
|
||
autoSwapToggle.checked = CONFIG.autoSwap;
|
||
autoSwapToggle.addEventListener('change', (e) => {
|
||
CONFIG.autoSwap = e.target.checked;
|
||
});
|
||
}
|
||
|
||
if (refreshChargesBtn) {
|
||
refreshChargesBtn.addEventListener("click", async () => {
|
||
refreshChargesBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'
|
||
refreshChargesBtn.disabled = true
|
||
|
||
try {
|
||
await updateStats()
|
||
} catch (error) {
|
||
console.error("Error refreshing charges:", error)
|
||
} finally {
|
||
refreshChargesBtn.innerHTML = '<i class="fas fa-sync"></i>'
|
||
refreshChargesBtn.disabled = false
|
||
}
|
||
})
|
||
}
|
||
}
|
||
if (statsContainer && statsBtn) {
|
||
statsContainer.style.display = "block";
|
||
statsBtn.innerHTML = '<i class="fas fa-chart-line"></i>';
|
||
statsBtn.title = "Hide Stats";
|
||
}
|
||
|
||
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.style.display !== "none"
|
||
if (isVisible) {
|
||
settingsContainer.style.animation = "settingsFadeOut 0.3s ease-out forwards"
|
||
setTimeout(() => {
|
||
settingsContainer.style.display = "none"
|
||
settingsContainer.style.animation = ""
|
||
}, 300)
|
||
} else {
|
||
settingsContainer.style.top = "50%"
|
||
settingsContainer.style.left = "50%"
|
||
settingsContainer.style.transform = "translate(-50%, -50%)"
|
||
settingsContainer.style.display = "block"
|
||
settingsContainer.style.animation = "settingsSlideIn 0.4s ease-out"
|
||
}
|
||
})
|
||
|
||
closeSettingsBtn.addEventListener("click", () => {
|
||
settingsContainer.style.animation = "settingsFadeOut 0.3s ease-out forwards"
|
||
setTimeout(() => {
|
||
settingsContainer.style.display = "none"
|
||
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(`Token source set to: ${sourceNames[state.tokenSource]}`, "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(`Batch mode set to: ${state.batchMode === 'random' ? 'Random Range' : 'Normal Fixed Size'}`, "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", (e) => {
|
||
const newLanguage = e.target.value
|
||
state.language = newLanguage
|
||
localStorage.setItem('wplace_language', 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");
|
||
|
||
if (overlayOpacitySlider && overlayOpacityValue) {
|
||
overlayOpacitySlider.addEventListener('input', (e) => {
|
||
const opacity = parseFloat(e.target.value);
|
||
state.overlayOpacity = opacity;
|
||
overlayOpacityValue.textContent = `${Math.round(opacity * 100)}%`;
|
||
});
|
||
}
|
||
|
||
// Speed slider event listener
|
||
const speedSlider = settingsContainer.querySelector("#speedSlider");
|
||
const speedValue = settingsContainer.querySelector("#speedValue");
|
||
if (speedSlider && speedValue) {
|
||
speedSlider.addEventListener('input', (e) => {
|
||
const speed = parseInt(e.target.value, 10);
|
||
state.paintingSpeed = speed;
|
||
speedValue.textContent = `${speed} (batch size)`;
|
||
saveBotSettings();
|
||
});
|
||
}
|
||
|
||
if (enableBlueMarbleToggle) {
|
||
enableBlueMarbleToggle.addEventListener('click', async () => {
|
||
state.blueMarbleEnabled = enableBlueMarbleToggle.checked;
|
||
if (state.imageLoaded && overlayManager.imageBitmap) {
|
||
Utils.showAlert("Re-processing overlay...", "info");
|
||
await overlayManager.processImageIntoChunks();
|
||
Utils.showAlert("Overlay updated!", "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("Notifications enabled.", "success");
|
||
else Utils.showAlert("Notifications permission denied.", "warning");
|
||
});
|
||
}
|
||
if (notifTestBtn) {
|
||
notifTestBtn.addEventListener("click", () => {
|
||
NotificationManager.notify("WPlace — Test", "This is a test notification.", "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 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 downloadPreviewBtn = resizeContainer.querySelector("#downloadPreviewBtn");
|
||
const clearIgnoredBtn = resizeContainer.querySelector('#clearIgnoredBtn');
|
||
|
||
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 = "Expand Mode"
|
||
} else {
|
||
compactBtn.innerHTML = '<i class="fas fa-compress"></i>'
|
||
compactBtn.title = "Compact Mode"
|
||
}
|
||
})
|
||
}
|
||
|
||
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 = "Restore"
|
||
} else {
|
||
container.classList.remove("wplace-minimized")
|
||
content.classList.remove("wplace-hidden")
|
||
minimizeBtn.innerHTML = '<i class="fas fa-minus"></i>'
|
||
minimizeBtn.title = "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(`Overlay ${isEnabled ? 'enabled' : 'disabled'}.`, '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 = "Restore"
|
||
}
|
||
} else {
|
||
container.classList.remove("wplace-minimized")
|
||
content.classList.remove("wplace-hidden")
|
||
if (minimizeBtn) {
|
||
minimizeBtn.innerHTML = '<i class="fas fa-minus"></i>'
|
||
minimizeBtn.title = "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("❌ Erro ao salvar progresso", "error")
|
||
}
|
||
})
|
||
}
|
||
|
||
if (loadBtn) {
|
||
loadBtn.addEventListener("click", () => {
|
||
// Check if initial setup is complete
|
||
if (!state.initialSetupComplete) {
|
||
Utils.showAlert("🔄 Please wait for the initial setup to complete before loading progress.", "warning");
|
||
return;
|
||
}
|
||
|
||
const savedData = Utils.loadProgress()
|
||
if (!savedData) {
|
||
updateUI("noSavedData", "warning")
|
||
Utils.showAlert(Utils.t("noSavedData"), "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) {
|
||
updateUI("dataLoaded", "success")
|
||
Utils.showAlert(Utils.t("dataLoaded"), "success")
|
||
updateDataButtons()
|
||
|
||
updateStats()
|
||
|
||
// 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;
|
||
} else {
|
||
uploadBtn.disabled = false;
|
||
selectPosBtn.disabled = false;
|
||
}
|
||
|
||
if (state.imageLoaded && state.startPosition && state.region && state.colorsChecked) {
|
||
startBtn.disabled = false
|
||
}
|
||
} else {
|
||
Utils.showAlert("❌ Erro ao carregar progresso", "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 () => {
|
||
// Check if initial setup is complete
|
||
if (!state.initialSetupComplete) {
|
||
Utils.showAlert("🔄 Please wait for the initial setup to complete before loading from file.", "warning");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const success = await Utils.loadProgressFromFile()
|
||
if (success) {
|
||
updateUI("fileLoaded", "success")
|
||
Utils.showAlert(Utils.t("fileLoaded"), "success")
|
||
updateDataButtons()
|
||
|
||
await updateStats()
|
||
|
||
// Restore overlay if image data was loaded from file
|
||
await Utils.restoreOverlayFromData().catch(error => {
|
||
console.error('Failed to restore overlay from file:', error);
|
||
});
|
||
|
||
if (state.colorsChecked) {
|
||
uploadBtn.disabled = false
|
||
selectPosBtn.disabled = false
|
||
resizeBtn.disabled = false
|
||
} else {
|
||
uploadBtn.disabled = false;
|
||
}
|
||
|
||
if (state.imageLoaded && state.startPosition && state.region && state.colorsChecked) {
|
||
startBtn.disabled = false
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error.message === "Invalid JSON file") {
|
||
Utils.showAlert(Utils.t("invalidFileFormat"), "error")
|
||
} else {
|
||
Utils.showAlert(Utils.t("fileError"), "error")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
updateUI = (messageKey, type = "default", params = {}) => {
|
||
const message = Utils.t(messageKey, params)
|
||
statusText.textContent = message
|
||
statusText.className = `wplace-status status-${type}`
|
||
statusText.style.animation = "none"
|
||
void statusText.offsetWidth
|
||
statusText.style.animation = "slideIn 0.3s ease-out"
|
||
}
|
||
|
||
updateStats = async () => {
|
||
localStorage.removeItem("lp");
|
||
const { id, charges, cooldown, max, droplets } = await WPlaceService.getCharges();
|
||
state.currentCharges = Math.floor(charges);
|
||
state.cooldown = cooldown;
|
||
state.maxCharges = Math.floor(max) > 1 ? Math.floor(max) : state.maxCharges;
|
||
// Evaluate notifications every time we refresh server-side charges
|
||
NotificationManager.maybeNotifyChargesReached();
|
||
|
||
if (cooldownSlider.max != state.maxCharges) {
|
||
cooldownSlider.max = state.maxCharges;
|
||
}
|
||
|
||
let imageStatsHTML = '';
|
||
if (state.imageLoaded) {
|
||
const progress = state.totalPixels > 0 ? Math.round((state.paintedPixels / state.totalPixels) * 100) : 0;
|
||
const remainingPixels = state.totalPixels - state.paintedPixels;
|
||
state.estimatedTime = Utils.calculateEstimatedTime(remainingPixels, state.currentCharges, state.cooldown);
|
||
if (progressBar) 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>
|
||
<div class="wplace-stat-item">
|
||
<div class="wplace-stat-label"><i class="fas fa-clock"></i> ${Utils.t("estimatedTime")}</div>
|
||
<div class="wplace-stat-value">${Utils.formatTime(state.estimatedTime)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
let colorSwatchesHTML = '';
|
||
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="ID: ${color.id}\nRGB: ${color.rgb.join(', ')}"></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
let totalAllCharges = 0;
|
||
if (state.allAccountsInfo.length > 0) {
|
||
totalAllCharges = state.allAccountsInfo.reduce((sum, acc) => sum + Math.floor(acc.Charges || 0), 0);
|
||
totalMaxCharges = state.allAccountsInfo.reduce((sum, acc) => sum + Math.floor(acc.Max || 0), 0);
|
||
}
|
||
|
||
if (statsArea) statsArea.innerHTML = `
|
||
${imageStatsHTML}
|
||
<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">${Math.floor(state.currentCharges)} / ${state.maxCharges}</div>
|
||
</div>
|
||
${state.colorsChecked ? `
|
||
<div class="wplace-colors-section">
|
||
<div class="wplace-stat-label"><i class="fas fa-palette"></i> Available Colors (${state.availableColors.length})</div>
|
||
<div class="wplace-stat-colors-grid">
|
||
${colorSwatchesHTML}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
|
||
// Sync with the accounts list
|
||
if (state.allAccountsInfo.length > 0 && id) {
|
||
const currentAccountInList = state.allAccountsInfo.find(acc => acc.ID === id);
|
||
if (currentAccountInList) {
|
||
currentAccountInList.Charges = state.currentCharges;
|
||
currentAccountInList.Max = state.maxCharges;
|
||
currentAccountInList.Droplets = droplets;
|
||
// Re-render the list to show the updated charge count
|
||
renderAccountsList();
|
||
}
|
||
}
|
||
}
|
||
|
||
updateDataButtons = () => {
|
||
const hasImageData = state.imageLoaded && state.imageData
|
||
saveBtn.disabled = !hasImageData
|
||
saveToFileBtn.disabled = !hasImageData
|
||
}
|
||
|
||
updateDataButtons()
|
||
|
||
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;
|
||
|
||
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 = 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 (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();
|
||
};
|
||
|
||
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 && 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 = 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
|
||
|
||
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;
|
||
|
||
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;
|
||
}
|
||
|
||
if (uploadBtn) {
|
||
uploadBtn.addEventListener("click", async () => {
|
||
const availableColors = Utils.extractAvailableColors();
|
||
if (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 });
|
||
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 = 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.imageLoaded = true
|
||
state.lastPosition = { x: 0, y: 0 }
|
||
|
||
// 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
|
||
}
|
||
|
||
updateStats()
|
||
updateDataButtons()
|
||
updateUI("imageLoaded", "success", { count: totalValidPixels })
|
||
} catch {
|
||
updateUI("imageError", "error")
|
||
}
|
||
})
|
||
}
|
||
|
||
if (resizeBtn) {
|
||
resizeBtn.addEventListener("click", () => {
|
||
if (state.imageLoaded && state.imageData.processor && state.colorsChecked) {
|
||
showResizeDialog(state.imageData.processor)
|
||
} else if (!state.colorsChecked) {
|
||
Utils.showAlert("Please upload an image first to capture available colors", "warning")
|
||
}
|
||
})
|
||
}
|
||
|
||
if (selectPosBtn) {
|
||
selectPosBtn.addEventListener("click", async () => {
|
||
if (state.selectingPosition) return
|
||
|
||
state.selectingPosition = true
|
||
state.startPosition = null
|
||
state.region = null
|
||
startBtn.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],
|
||
}
|
||
state.lastPosition = { x: 0, y: 0 }
|
||
|
||
await overlayManager.setPosition(state.startPosition, state.region);
|
||
|
||
if (state.imageLoaded) {
|
||
startBtn.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 false
|
||
}
|
||
await ensureToken()
|
||
if (!turnstileToken) return false
|
||
|
||
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();
|
||
return true
|
||
} catch {
|
||
updateUI("paintingError", "error")
|
||
return false
|
||
} finally {
|
||
state.running = false
|
||
stopBtn.disabled = true
|
||
saveBtn.disabled = false
|
||
|
||
if (!state.stopFlag) {
|
||
startBtn.disabled = true
|
||
uploadBtn.disabled = false
|
||
selectPosBtn.disabled = false
|
||
resizeBtn.disabled = false
|
||
} else {
|
||
startBtn.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("paintingStopped", "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 && cooldownValue) {
|
||
cooldownSlider.addEventListener("input", (e) => {
|
||
const threshold = parseInt(e.target.value);
|
||
state.cooldownChargeThreshold = threshold;
|
||
cooldownValue.textContent = threshold;
|
||
saveBotSettings();
|
||
NotificationManager.resetEdgeTracking(); // prevent spurious notify after threshold change
|
||
});
|
||
}
|
||
|
||
loadBotSettings();
|
||
// Ensure notification poller reflects current settings
|
||
NotificationManager.syncFromState();
|
||
}
|
||
|
||
async function processImage() {
|
||
const { width, height, pixels } = state.imageData
|
||
const { x: startX, y: startY } = state.startPosition
|
||
const { x: regionX, y: regionY } = state.region
|
||
|
||
const tThresh2 = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
|
||
const isEligibleAt = (x, y) => {
|
||
const idx = (y * width + x) * 4;
|
||
const r = pixels[idx], g = pixels[idx + 1], b = pixels[idx + 2], a = pixels[idx + 3];
|
||
if (a < tThresh2) return false;
|
||
if (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b)) return false;
|
||
return true;
|
||
};
|
||
|
||
let startRow = 0;
|
||
let startCol = 0;
|
||
let foundStart = false;
|
||
let seen = 0;
|
||
const target = Math.max(0, Math.min(state.paintedPixels || 0, width * height));
|
||
for (let y = 0; y < height && !foundStart; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
if (!isEligibleAt(x, y)) continue;
|
||
if (seen === target) { startRow = y; startCol = x; foundStart = true; break; }
|
||
seen++;
|
||
}
|
||
}
|
||
if (!foundStart) { startRow = height; startCol = 0; }
|
||
|
||
let pixelBatch = null;
|
||
let skippedPixels = { transparent: 0, white: 0, alreadyPainted: 0 };
|
||
|
||
try {
|
||
outerLoop: for (let y = startRow; y < height; y++) {
|
||
for (let x = y === startRow ? startCol : 0; x < width; x++) {
|
||
if (state.stopFlag) {
|
||
if (pixelBatch && pixelBatch.pixels.length > 0) {
|
||
console.log(`🎯 Sending final batch before stop with ${pixelBatch.pixels.length} pixels`);
|
||
const success = await sendBatchWithRetry(pixelBatch.pixels, pixelBatch.regionX, pixelBatch.regionY);
|
||
if (success) {
|
||
pixelBatch.pixels.forEach(() => { state.paintedPixels++; });
|
||
state.currentCharges -= pixelBatch.pixels.length;
|
||
updateStats();
|
||
}
|
||
}
|
||
state.lastPosition = { x, y }
|
||
updateUI("paintingPaused", "warning", { x, y })
|
||
break outerLoop
|
||
}
|
||
|
||
|
||
const idx = (y * width + x) * 4
|
||
const r = pixels[idx]
|
||
const g = pixels[idx + 1]
|
||
const b = pixels[idx + 2]
|
||
const alpha = pixels[idx + 3]
|
||
|
||
const tThresh2 = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
|
||
if (alpha < tThresh2 || (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))) {
|
||
if (alpha < tThresh2) {
|
||
skippedPixels.transparent++;
|
||
} else {
|
||
skippedPixels.white++;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let targetRgb;
|
||
if (Utils.isWhitePixel(r, g, b)) {
|
||
targetRgb = [255, 255, 255];
|
||
} else {
|
||
targetRgb = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
|
||
}
|
||
|
||
const colorId = findClosestColor([r, g, b], state.availableColors);
|
||
|
||
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;
|
||
|
||
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 (switching to region ${regionX + adderX},${regionY + adderY})`);
|
||
const success = await sendBatchWithRetry(pixelBatch.pixels, pixelBatch.regionX, pixelBatch.regionY);
|
||
|
||
if (success) {
|
||
pixelBatch.pixels.forEach((p) => {
|
||
state.paintedPixels++;
|
||
// Mark pixel as painted in map
|
||
Utils.markPixelPainted(p.x, p.y, pixelBatch.regionX, pixelBatch.regionY);
|
||
});
|
||
state.currentCharges -= pixelBatch.pixels.length;
|
||
updateUI("paintingProgress", "default", {
|
||
painted: state.paintedPixels,
|
||
total: state.totalPixels,
|
||
})
|
||
|
||
// Use smart save instead of fixed interval
|
||
Utils.performSmartSave();
|
||
|
||
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && pixelBatch.pixels.length > 0) {
|
||
// paintingSpeed now represents batch size, so add a small delay based on batch size
|
||
const batchDelayFactor = Math.max(1, 100 / state.paintingSpeed); // Larger batches = less delay
|
||
const totalDelay = Math.max(100, batchDelayFactor * pixelBatch.pixels.length);
|
||
await Utils.sleep(totalDelay)
|
||
}
|
||
updateStats();
|
||
} else {
|
||
// If batch failed after all retries, stop painting to prevent infinite loops
|
||
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
|
||
state.stopFlag = true;
|
||
break outerLoop;
|
||
}
|
||
}
|
||
|
||
pixelBatch = {
|
||
regionX: regionX + adderX,
|
||
regionY: regionY + adderY,
|
||
pixels: []
|
||
};
|
||
}
|
||
|
||
|
||
try {
|
||
const tileRegionX = pixelBatch ? (pixelBatch.regionX) : (regionX + adderX);
|
||
const tileRegionY = pixelBatch ? (pixelBatch.regionY) : (regionY + adderY);
|
||
const tileKeyParts = [(regionX + adderX), (regionY + adderY)];
|
||
const existingColorRGBA = await overlayManager.getTilePixelColor(tileKeyParts[0], tileKeyParts[1], pixelX, pixelY).catch(() => null);
|
||
if (existingColorRGBA && Array.isArray(existingColorRGBA)) {
|
||
const [er, eg, eb] = existingColorRGBA;
|
||
const existingColorId = findClosestColor([er, eg, eb], state.availableColors);
|
||
// console.log(`pixel at (${pixelX}, ${pixelY}) has color ${existingColorId} it should be ${colorId}`);
|
||
if (existingColorId === colorId) {
|
||
skippedPixels.alreadyPainted++;
|
||
console.log(`Skipped already painted pixel at (${pixelX}, ${pixelY})`);
|
||
continue; // Skip
|
||
}
|
||
}
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
|
||
pixelBatch.pixels.push({
|
||
x: pixelX,
|
||
y: pixelY,
|
||
color: colorId,
|
||
localX: x,
|
||
localY: y,
|
||
});
|
||
|
||
// Calculate batch size based on mode (normal/random)
|
||
const maxBatchSize = calculateBatchSize();
|
||
if (pixelBatch.pixels.length >= maxBatchSize) {
|
||
const modeText = state.batchMode === 'random' ? `random (${state.randomBatchMin}-${state.randomBatchMax})` : 'normal';
|
||
console.log(`📦 Sending batch with ${pixelBatch.pixels.length} pixels (mode: ${modeText}, target: ${maxBatchSize})`);
|
||
const success = await sendBatchWithRetry(pixelBatch.pixels, pixelBatch.regionX, pixelBatch.regionY);
|
||
|
||
if (success) {
|
||
pixelBatch.pixels.forEach((pixel) => {
|
||
state.paintedPixels++;
|
||
// Mark pixel as painted in map
|
||
Utils.markPixelPainted(pixel.x, pixel.y, pixelBatch.regionX, pixelBatch.regionY);
|
||
})
|
||
|
||
state.currentCharges -= pixelBatch.pixels.length;
|
||
updateStats()
|
||
updateUI("paintingProgress", "default", {
|
||
painted: state.paintedPixels,
|
||
total: state.totalPixels,
|
||
})
|
||
|
||
// Use smart save instead of fixed interval
|
||
Utils.performSmartSave();
|
||
|
||
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && pixelBatch.pixels.length > 0) {
|
||
const delayPerPixel = 1000 / state.paintingSpeed // ms per pixel
|
||
const totalDelay = Math.max(100, delayPerPixel * pixelBatch.pixels.length) // minimum 100ms
|
||
await Utils.sleep(totalDelay)
|
||
}
|
||
} else {
|
||
// If batch failed after all retries, stop painting to prevent infinite loops
|
||
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
|
||
state.stopFlag = true;
|
||
break outerLoop;
|
||
}
|
||
|
||
pixelBatch.pixels = [];
|
||
}
|
||
if (!CONFIG.autoSwap) {
|
||
while (state.currentCharges < state.cooldownChargeThreshold && !state.stopFlag) {
|
||
const { charges, cooldown } = await WPlaceService.getCharges();
|
||
state.currentCharges = Math.floor(charges);
|
||
state.cooldown = cooldown;
|
||
|
||
if (state.currentCharges >= state.cooldownChargeThreshold) {
|
||
// Edge-trigger a notification the instant threshold is crossed
|
||
NotificationManager.maybeNotifyChargesReached(true);
|
||
updateStats();
|
||
break;
|
||
}
|
||
|
||
// Enable save button during cooldown wait
|
||
saveBtn.disabled = false;
|
||
|
||
updateUI("noChargesThreshold", "warning", {
|
||
time: Utils.formatTime(state.cooldown),
|
||
threshold: state.cooldownChargeThreshold,
|
||
current: state.currentCharges
|
||
});
|
||
await updateStats();
|
||
|
||
// Allow auto save during cooldown
|
||
Utils.performSmartSave();
|
||
|
||
await Utils.sleep(state.cooldown);
|
||
}
|
||
}
|
||
else {
|
||
if (state.currentCharges < state.cooldownChargeThreshold && !state.stopFlag) {
|
||
console.log("⚠️ Charges too low, swapping to next account...");
|
||
|
||
const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
|
||
if (accounts.length === 0) {
|
||
console.warn("❌ No accounts available, stopping painting.");
|
||
state.stopFlag = true;
|
||
return;
|
||
}
|
||
|
||
state.accountIndex = (state.accountIndex + 1) % accounts.length;
|
||
console.log("🔄 Switching to account index:", state.accountIndex);
|
||
|
||
const nextToken = accounts[state.accountIndex];
|
||
console.log("🔑 Next token:", nextToken);
|
||
|
||
if (!nextToken) {
|
||
console.warn("⚠️ Invalid token, skipping...");
|
||
return;
|
||
}
|
||
|
||
const swapSuccess = await swapAccountTrigger(nextToken);
|
||
|
||
if (swapSuccess) {
|
||
const { charges, cooldown } = await WPlaceService.getCharges();
|
||
state.currentCharges = Math.floor(charges);
|
||
state.cooldown = cooldown;
|
||
Utils.performSmartSave();
|
||
updateStats();
|
||
} else {
|
||
console.error("❌ Failed to swap account after confirmation timeout. Stopping loop.");
|
||
state.stopFlag = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Disable save button again after exiting wait loop (back to normal painting)
|
||
if (!state.stopFlag) {
|
||
saveBtn.disabled = true;
|
||
}
|
||
if (state.stopFlag) break outerLoop;
|
||
|
||
}
|
||
}
|
||
|
||
if (pixelBatch && pixelBatch.pixels.length > 0 && !state.stopFlag) {
|
||
console.log(`🏁 Sending final batch with ${pixelBatch.pixels.length} pixels`);
|
||
const success = await sendBatchWithRetry(pixelBatch.pixels, pixelBatch.regionX, pixelBatch.regionY);
|
||
if (success) {
|
||
pixelBatch.pixels.forEach((pixel) => {
|
||
state.paintedPixels++;
|
||
// Mark pixel as painted in map
|
||
Utils.markPixelPainted(pixel.x, pixel.y, pixelBatch.regionX, pixelBatch.regionY);
|
||
})
|
||
state.currentCharges -= pixelBatch.pixels.length;
|
||
// Final save with painted map
|
||
Utils.saveProgress();
|
||
|
||
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && pixelBatch.pixels.length > 0) {
|
||
const delayPerPixel = 1000 / state.paintingSpeed // ms per pixel
|
||
const totalDelay = Math.max(100, delayPerPixel * pixelBatch.pixels.length) // minimum 100ms
|
||
await Utils.sleep(totalDelay)
|
||
}
|
||
} else {
|
||
// If final batch failed after retries, log it
|
||
console.warn(`⚠️ Final batch failed with ${pixelBatch.pixels.length} pixels after all retries.`);
|
||
}
|
||
}
|
||
} finally {
|
||
if (window._chargesInterval) clearInterval(window._chargesInterval)
|
||
window._chargesInterval = null
|
||
}
|
||
|
||
if (state.stopFlag) {
|
||
updateUI("paintingStopped", "warning")
|
||
// Save progress when stopped to preserve painted map
|
||
Utils.saveProgress()
|
||
} else {
|
||
updateUI("paintingComplete", "success", { count: state.paintedPixels })
|
||
state.lastPosition = { x: 0, y: 0 }
|
||
// Keep painted map until user starts new project
|
||
// state.paintedMap = null // Commented out to preserve data
|
||
Utils.saveProgress() // Save final complete state
|
||
overlayManager.clear();
|
||
const toggleOverlayBtn = document.getElementById('toggleOverlayBtn');
|
||
if (toggleOverlayBtn) {
|
||
toggleOverlayBtn.classList.remove('active');
|
||
toggleOverlayBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
// Log skip statistics
|
||
console.log(`📊 Pixel Statistics:`);
|
||
console.log(` Painted: ${state.paintedPixels}`);
|
||
console.log(` Skipped - Transparent: ${skippedPixels.transparent}`);
|
||
console.log(` Skipped - White (disabled): ${skippedPixels.white}`);
|
||
console.log(` Skipped - Already painted: ${skippedPixels.alreadyPainted}`);
|
||
console.log(` Total processed: ${state.paintedPixels + skippedPixels.transparent + skippedPixels.white + skippedPixels.alreadyPainted}`);
|
||
|
||
updateStats()
|
||
}
|
||
|
||
const loadAccountNames = () => {
|
||
try {
|
||
return JSON.parse(localStorage.getItem("wplace-account-names")) || {};
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
const saveAccountNames = (names) => {
|
||
try {
|
||
localStorage.setItem("wplace-account-names", JSON.stringify(names));
|
||
} catch (e) {
|
||
console.warn("Could not save account names:", e);
|
||
}
|
||
};
|
||
|
||
async function editAccountName(token, currentName) {
|
||
const newName = prompt("Enter a new name for this account:", currentName);
|
||
if (newName === null) return; // User cancelled
|
||
|
||
if (newName.trim() === "") {
|
||
Utils.showAlert("Account name cannot be empty.", "warning");
|
||
return;
|
||
}
|
||
|
||
const names = loadAccountNames();
|
||
names[token] = newName.trim();
|
||
saveAccountNames(names);
|
||
|
||
const accountInfo = state.allAccountsInfo.find(acc => acc.token === token);
|
||
if (accountInfo) {
|
||
accountInfo.displayName = newName.trim();
|
||
}
|
||
|
||
renderAccountsList();
|
||
Utils.showAlert("Account name updated.", "success");
|
||
}
|
||
|
||
async function deleteAccount(token, name) {
|
||
if (!confirm(`Are you sure you want to delete the account "${name}"?\nThis action cannot be undone.`)) {
|
||
return;
|
||
}
|
||
|
||
let accounts = JSON.parse(localStorage.getItem("accounts")) || [];
|
||
const initialCount = accounts.length;
|
||
accounts = accounts.filter(t => t !== token);
|
||
if (accounts.length < initialCount) {
|
||
localStorage.setItem("accounts", JSON.stringify(accounts));
|
||
}
|
||
|
||
const names = loadAccountNames();
|
||
delete names[token];
|
||
saveAccountNames(names);
|
||
|
||
state.allAccountsInfo = state.allAccountsInfo.filter(acc => acc.token !== token);
|
||
renderAccountsList();
|
||
Utils.showAlert(`Account "${name}" has been deleted.`, "success");
|
||
}
|
||
|
||
function renderAccountsList() {
|
||
const accountsListArea = document.getElementById('accountsListArea');
|
||
if (!accountsListArea) return;
|
||
|
||
accountsListArea.innerHTML = '';
|
||
if (state.allAccountsInfo.length === 0) {
|
||
accountsListArea.innerHTML = `<div class="wplace-stat-item" style="opacity: 0.5;">No account data. Click <i class="fas fa-users-cog"></i> to refresh.</div>`;
|
||
return;
|
||
}
|
||
|
||
const theme = getCurrentTheme();
|
||
state.allAccountsInfo.forEach((info, index) => {
|
||
const item = Utils.createElement('div', {
|
||
className: `wplace-account-item ${info.isCurrent ? 'is-current' : ''}`
|
||
});
|
||
|
||
const displayName = info.displayName || `Account ${index + 1}`; // this line is fine
|
||
|
||
const details = Utils.createElement('div', { className: 'wplace-account-details' });
|
||
details.appendChild(Utils.createElement('div', { className: 'wplace-account-name', title: displayName }, displayName));
|
||
// details.appendChild(Utils.createElement('div', { className: 'wplace-account-id' }, `ID: ${info.ID}`));
|
||
|
||
let stats;
|
||
if (info.error) {
|
||
stats = Utils.createElement('div', { className: 'wplace-account-stats', style: { color: theme.error } }, 'Error');
|
||
} else {
|
||
stats = Utils.createElement('div', {
|
||
className: 'wplace-account-stats',
|
||
innerHTML: `
|
||
<span><i class="fas fa-bolt"></i> ${Math.floor(info.Charges || 0)}/${Math.floor(info.Max || 0)}</span>
|
||
<span><i class="fas fa-tint"></i> ${Math.floor(info.Droplets || 0)}</span>
|
||
`
|
||
});
|
||
}
|
||
|
||
const controls = Utils.createElement('div', { className: 'wplace-account-controls' });
|
||
const editBtn = Utils.createElement('button', {
|
||
className: 'wplace-account-btn edit-btn',
|
||
title: 'Edit Name',
|
||
innerHTML: '<i class="fas fa-edit"></i>'
|
||
});
|
||
editBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
editAccountName(info.token, displayName);
|
||
});
|
||
|
||
const deleteBtn = Utils.createElement('button', {
|
||
className: 'wplace-account-btn delete-btn',
|
||
title: 'Delete Account',
|
||
innerHTML: '<i class="fas fa-trash-alt"></i>'
|
||
});
|
||
deleteBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
deleteAccount(info.token, displayName);
|
||
});
|
||
|
||
controls.appendChild(editBtn);
|
||
controls.appendChild(deleteBtn);
|
||
|
||
item.appendChild(details);
|
||
item.appendChild(stats);
|
||
item.appendChild(controls);
|
||
|
||
accountsListArea.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function fetchAllAccountDetails() {
|
||
if (state.isFetchingAllAccounts) {
|
||
Utils.showAlert("Already fetching account details.", "warning");
|
||
return;
|
||
}
|
||
|
||
// Check if bot is running and auto-swap is enabled - prevent refresh during active painting
|
||
if (state.running && CONFIG.autoSwap) {
|
||
Utils.showAlert("Cannot refresh accounts while auto-painting is active. Please stop painting first.", "warning");
|
||
return;
|
||
}
|
||
|
||
// If auto-swap is enabled but not running, check cooldown before allowing refresh
|
||
if (CONFIG.autoSwap && state.currentCharges < state.cooldownChargeThreshold) {
|
||
const timeLeft = Utils.formatTime(state.cooldown);
|
||
Utils.showAlert(`Account switching is on cooldown. Please wait ${timeLeft} before refreshing accounts.`, "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;">Initializing...</div>`;
|
||
}
|
||
|
||
let originalToken = null;
|
||
// Store original auto-swap setting and temporarily disable it during refresh
|
||
const originalAutoSwap = CONFIG.autoSwap;
|
||
CONFIG.autoSwap = false;
|
||
|
||
try {
|
||
await getAccounts();
|
||
const accountsTokens = JSON.parse(localStorage.getItem("accounts")) || [];
|
||
if (accountsTokens.length === 0) {
|
||
if (accountsListArea) accountsListArea.innerHTML = `<div class="wplace-stat-item" style="opacity: 0.5;">No accounts found.</div>`;
|
||
return;
|
||
}
|
||
|
||
const { id: originalId } = await WPlaceService.getCharges();
|
||
state.allAccountsInfo = [];
|
||
renderAccountsList();
|
||
|
||
const accountNames = loadAccountNames();
|
||
for (let i = 0; i < accountsTokens.length; i++) {
|
||
const token = accountsTokens[i];
|
||
|
||
console.log(`🔄 Refreshing account ${i + 1}/${accountsTokens.length} for data collection only...`);
|
||
await swapAccountTrigger(token);
|
||
|
||
let retries = 0;
|
||
let swapped = false;
|
||
let fetchedInfo = null;
|
||
while (retries < 5 && !swapped) {
|
||
await Utils.sleep(1000);
|
||
try {
|
||
fetchedInfo = await WPlaceService.fetchCheck();
|
||
if (fetchedInfo.ID) swapped = true;
|
||
} catch (e) { retries++; }
|
||
}
|
||
|
||
if (swapped) {
|
||
await fetchAccount();
|
||
// await purchase("max_charges");
|
||
const displayName = accountNames[token] || `Account ${i + 1}`;
|
||
if (fetchedInfo.ID === originalId) originalToken = token;
|
||
state.allAccountsInfo.push({ ...fetchedInfo, token, displayName, isCurrent: fetchedInfo.ID === originalId });
|
||
} else {
|
||
const displayName = accountNames[token] || `Account ${i + 1}`;
|
||
state.allAccountsInfo.push({ token, ID: `...${token.slice(-4)}`, displayName, error: 'Failed to fetch' });
|
||
}
|
||
renderAccountsList();
|
||
}
|
||
} catch (error) {
|
||
console.error("Error fetching all account details:", error);
|
||
if (accountsListArea) accountsListArea.innerHTML = `<div class="wplace-stat-item" style="color: ${getCurrentTheme().error};">Error loading accounts.</div>`;
|
||
} finally {
|
||
// Restore original auto-swap setting
|
||
CONFIG.autoSwap = originalAutoSwap;
|
||
|
||
if (originalToken) {
|
||
console.log("🔄 Switching back to original account after data collection...");
|
||
await swapAccountTrigger(originalToken);
|
||
}
|
||
await Utils.sleep(1000);
|
||
|
||
// After switching back, update stats and sync the list
|
||
const meData = await WPlaceService.getCharges();
|
||
state.currentCharges = Math.floor(meData.charges);
|
||
state.cooldown = meData.cooldown;
|
||
state.maxCharges = Math.floor(meData.max) > 1 ? Math.floor(meData.max) : state.maxCharges;
|
||
const currentAccountInList = state.allAccountsInfo.find(acc => acc.ID === meData.id);
|
||
if (currentAccountInList) {
|
||
currentAccountInList.Charges = state.currentCharges;
|
||
currentAccountInList.Max = state.maxCharges;
|
||
currentAccountInList.Droplets = meData.droplets;
|
||
}
|
||
await updateStats(); // This will re-render the main stats
|
||
renderAccountsList(); // This will re-render the list with synced data
|
||
|
||
state.isFetchingAllAccounts = false;
|
||
if (refreshBtn) {
|
||
refreshBtn.innerHTML = '<i class="fas fa-users-cog"></i>';
|
||
refreshBtn.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Helper function to calculate batch size based on mode
|
||
function calculateBatchSize() {
|
||
let targetBatchSize;
|
||
|
||
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;
|
||
}
|
||
|
||
// Always limit by available charges
|
||
const maxAllowed = Math.floor(state.currentCharges);
|
||
const finalBatchSize = Math.min(targetBatchSize, maxAllowed);
|
||
|
||
return 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}, regenerating...`);
|
||
updateUI("captchaSolving", "warning");
|
||
try {
|
||
await handleCaptcha();
|
||
// Don't count token regeneration as a failed attempt
|
||
attempt--;
|
||
continue;
|
||
} catch (e) {
|
||
console.error(`❌ Token regeneration failed 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 = turnstileToken;
|
||
|
||
// Generate new token if we don't have one
|
||
if (!token) {
|
||
try {
|
||
console.log("🔑 Generating Turnstile token for pixel batch...");
|
||
token = await handleCaptcha();
|
||
turnstileToken = token; // Store for potential reuse
|
||
} catch (error) {
|
||
console.error("❌ Failed to generate Turnstile token:", error);
|
||
tokenPromise = new Promise((resolve) => { _resolveToken = resolve });
|
||
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: randStr(10) };
|
||
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. Turnstile token might be invalid or expired.")
|
||
|
||
// Try to generate a new token and retry once
|
||
try {
|
||
console.log("🔄 Regenerating Turnstile token after 403...");
|
||
token = await handleCaptcha();
|
||
turnstileToken = token;
|
||
|
||
// Retry the request with new token
|
||
const retryPayload = { coords, colors, t: token, fp: randStr(10) };
|
||
var wasmtoken = await createWasmToken(regionX, regionY, retryPayload);
|
||
const retryRes = 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(retryPayload),
|
||
}
|
||
);
|
||
|
||
if (retryRes.status === 403) {
|
||
turnstileToken = null;
|
||
tokenPromise = new Promise((resolve) => { _resolveToken = resolve });
|
||
return "token_error";
|
||
}
|
||
|
||
const retryData = await retryRes.json();
|
||
return retryData?.painted === pixelBatch.length;
|
||
|
||
} catch (retryError) {
|
||
console.error("❌ Token regeneration failed:", retryError);
|
||
turnstileToken = null;
|
||
tokenPromise = new Promise((resolve) => { _resolveToken = resolve });
|
||
return "token_error";
|
||
}
|
||
}
|
||
|
||
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"
|
||
randomBatchMin: state.randomBatchMin,
|
||
randomBatchMax: state.randomBatchMax,
|
||
cooldownChargeThreshold: state.cooldownChargeThreshold,
|
||
autoSwap: CONFIG.autoSwap,
|
||
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,
|
||
resizeSettings: state.resizeSettings,
|
||
originalImage: state.originalImage,
|
||
// 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,
|
||
};
|
||
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.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;
|
||
CONFIG.autoSwap = settings.autoSwap ?? false;
|
||
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.resizeSettings = settings.resizeSettings ?? null;
|
||
state.originalImage = settings.originalImage ?? null;
|
||
// 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;
|
||
}
|
||
|
||
const speedSlider = document.getElementById('speedSlider');
|
||
if (speedSlider) speedSlider.value = state.paintingSpeed;
|
||
const speedValue = document.getElementById('speedValue');
|
||
if (speedValue) speedValue.textContent = `${state.paintingSpeed} (batch size)`;
|
||
|
||
const enableSpeedToggle = document.getElementById('enableSpeedToggle');
|
||
if (enableSpeedToggle) enableSpeedToggle.checked = CONFIG.PAINTING_SPEED_ENABLED;
|
||
|
||
// 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');
|
||
if (cooldownSlider) cooldownSlider.value = state.cooldownChargeThreshold;
|
||
const cooldownValue = document.getElementById('cooldownValue');
|
||
if (cooldownValue) cooldownValue.textContent = state.cooldownChargeThreshold;
|
||
|
||
const autoSwapToggle = document.getElementById('autoSwapToggle');
|
||
if (autoSwapToggle) autoSwapToggle.checked = CONFIG.autoSwap;
|
||
|
||
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!");
|
||
|
||
// 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");
|
||
}
|
||
|
||
// Show a notification that file operations are now available
|
||
Utils.showAlert("📂 File operations (Load/Upload) are now available!", "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");
|
||
|
||
// Pre-load Turnstile script first to avoid delays later
|
||
await Utils.loadTurnstile();
|
||
|
||
const token = await handleCaptchaWithRetry();
|
||
if (token) {
|
||
setTurnstileToken(token);
|
||
console.log("✅ Startup token generated successfully");
|
||
updateUI("tokenReady", "success");
|
||
Utils.showAlert("🔑 Token generator ready!", "success");
|
||
enableFileOperations(); // Enable file operations since initial setup is complete
|
||
} else {
|
||
console.warn("⚠️ Startup token generation failed, 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.warn("⚠️ Startup token generation failed:", 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();
|
||
async function createWasmToken(regionX,regionY, payload) {
|
||
try {
|
||
// Load the Pawtect module and WASM
|
||
|
||
async function getModURL(update) {
|
||
if (update === true || localStorage.getItem('pawtect-wasm-location') === null) {
|
||
const rootHTML = (await (await fetch(window.location.href)).text()).split('\n')
|
||
|
||
for (const line of rootHTML) {
|
||
if (line.includes('<link rel="modulepreload" href="./_app/immutable/chunks/')) {
|
||
const url = line.substring(line.indexOf('href="') + 7, line.length - 2)
|
||
const content = (await (await fetch(window.location.href + url)).text())
|
||
|
||
if (content.includes('pawtect_wasm_bg.wasm')) {
|
||
console.log(`✅ Found the Pawtect module and WASM at: ${url}`)
|
||
localStorage.setItem('pawtect-wasm-location', url)
|
||
return url
|
||
}
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const url = localStorage.getItem('pawtect-wasm-location')
|
||
if (url !== null) {
|
||
console.log(`✅ Found the Pawtect module and WASM at: ${url} (Cached)`)
|
||
}
|
||
|
||
return url
|
||
}
|
||
|
||
let modURL = await getModURL()
|
||
if (modURL === null) {
|
||
console.log('❌ Failed to locate the Pawtect module and WASM')
|
||
return null
|
||
}
|
||
|
||
let mod
|
||
try {
|
||
mod = await import(modURL);
|
||
} catch (err) {
|
||
console.error(err)
|
||
await getModURL(true)
|
||
return await createWasmToken(regionX, regionY, payload)
|
||
}
|
||
|
||
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('📊 Results:');
|
||
console.log(' Input coords: [1245984, 1088]');
|
||
console.log(' Token length:', token?.length || 0);
|
||
console.log(' Token preview:', token?.substring(0, 50) + '...');
|
||
console.log('');
|
||
console.log('🔑 Full token:');
|
||
console.log(token);
|
||
return token;
|
||
} catch (error) {
|
||
console.error('❌ Failed to generate fp parameter:', error);
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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 = fetch("https://backend.wplace.live/purchase", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "text/plain;charset=UTF-8"
|
||
},
|
||
body: JSON.stringify(payload),
|
||
credentials: "include"
|
||
});
|
||
console.log("Fetch POST return :", res);
|
||
const { droplets: newDroplets } = await WPlaceService.getCharges();
|
||
if (droplets != newDroplets) {
|
||
console.log("Successfully bought", amounts * chargeMultiplier, type.replace('_', ' '), ".");
|
||
}
|
||
else {
|
||
console.log("Failed to buy charges");
|
||
}
|
||
} catch (e) {
|
||
console.error("An error occurred during the purchase:", e);
|
||
}
|
||
}
|
||
async function waitForCookieSet(timeout = 10000) {
|
||
return new Promise((resolve, reject) => {
|
||
const onMessage = (event) => {
|
||
if (event.source !== window) return;
|
||
const data = event.data || {};
|
||
if (data.type === 'cookieSet') {
|
||
window.removeEventListener('message', onMessage);
|
||
clearTimeout(timer);
|
||
resolve(true);
|
||
}
|
||
};
|
||
const timer = setTimeout(() => {
|
||
window.removeEventListener('message', onMessage);
|
||
reject(new Error('cookieSet timeout'));
|
||
}, timeout);
|
||
window.addEventListener('message', onMessage);
|
||
});
|
||
}
|
||
async function swapAccountTrigger(token) {
|
||
localStorage.removeItem("lp");
|
||
if (!token) {
|
||
console.error('❌ Cannot swap account: token is null or undefined');
|
||
return false;
|
||
}
|
||
console.log("Sending token to extension...");
|
||
window.postMessage({
|
||
source: 'my-userscript',
|
||
type: 'setCookie',
|
||
value: token
|
||
}, '*');
|
||
try {
|
||
await waitForCookieSet(10000);
|
||
console.log('✅ Cookie set confirmed');
|
||
return true;
|
||
} catch (e) {
|
||
console.warn('⚠️ No cookieSet confirmation:', e.message);
|
||
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") {
|
||
window.removeEventListener("message", handler);
|
||
// Save to localStorage
|
||
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);
|
||
}
|
||
createUI().then(() => {
|
||
// Generate token automatically after UI is ready
|
||
setTimeout(initializeTokenGenerator, 1000);
|
||
|
||
// 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('Advanced color settings reset.', 'success');
|
||
});
|
||
};
|
||
// Delay to ensure resize UI built
|
||
setTimeout(advancedInit, 500);
|
||
|
||
// Add cleanup on page unload
|
||
window.addEventListener('beforeunload', () => {
|
||
Utils.cleanupTurnstile();
|
||
});
|
||
})
|
||
})() |