`
: ''
}
`;
// should be after statsArea.innerHTML = '...'. todo make full stats ui update partial
updateChargeStatsDisplay(intervalMs);
};
updateDataButtons = () => {
const hasImageData = state.imageLoaded && state.imageData;
saveBtn.disabled = !hasImageData;
saveToFileBtn.disabled = !hasImageData;
};
updateDataButtons();
function showMoveArtworkPanel() {
// Create move artwork control panel like status panels
const movePanel = document.createElement('div');
movePanel.id = 'moveArtworkPanel';
movePanel.className = 'wplace-move-panel';
movePanel.innerHTML = `
Move Artwork
1px
`;
// No overlay - append directly to body like other panels
document.body.appendChild(movePanel);
// Make the panel draggable
makeDraggable(movePanel);
// Add event listeners for movement
let currentMessageTimeout = null;
function moveArtwork(deltaX, deltaY) {
if (state.startPosition && state.region) {
const newX = state.startPosition.x + deltaX;
const newY = state.startPosition.y + deltaY;
console.log(`🔄 Moving artwork from (${state.startPosition.x}, ${state.startPosition.y}) to (${newX}, ${newY})`);
state.startPosition.x = newX;
state.startPosition.y = newY;
// Clear any existing message timeout and alerts immediately
if (currentMessageTimeout) {
clearTimeout(currentMessageTimeout);
currentMessageTimeout = null;
}
Utils.hideAlert(); // Hide current alert immediately
// Update overlay position if available
if (overlayManager) {
overlayManager.setPosition(state.startPosition, state.region)
.then(() => {
console.log('✅ Overlay position updated');
Utils.showAlert(`Moved to position (${newX}, ${newY})`, 'success');
// Set new timeout for this message
currentMessageTimeout = setTimeout(() => {
Utils.hideAlert();
currentMessageTimeout = null;
}, 1500); // Message disappears after 1.5 seconds
})
.catch(err => {
console.error('❌ Failed to update overlay:', err);
Utils.showAlert('Failed to update overlay position', 'error');
});
} else {
Utils.showAlert(`Position updated to (${newX}, ${newY})`, 'success');
// Set new timeout for this message
currentMessageTimeout = setTimeout(() => {
Utils.hideAlert();
currentMessageTimeout = null;
}, 1500);
}
} else {
Utils.showAlert('Please select a position first before moving artwork', 'warning');
}
}
document.getElementById('moveUp').addEventListener('click', () => moveArtwork(0, -1));
document.getElementById('moveDown').addEventListener('click', () => moveArtwork(0, 1));
document.getElementById('moveLeft').addEventListener('click', () => moveArtwork(-1, 0));
document.getElementById('moveRight').addEventListener('click', () => moveArtwork(1, 0));
// Close panel
function closeMovePanel() {
if (currentMessageTimeout) {
clearTimeout(currentMessageTimeout);
currentMessageTimeout = null;
}
Utils.hideAlert();
document.body.removeChild(movePanel);
}
document.getElementById('closeMovePanel').addEventListener('click', closeMovePanel);
}
function showResizeDialog(processor) {
let baseProcessor = processor;
let width, height;
if (state.originalImage?.dataUrl) {
baseProcessor = new ImageProcessor(state.originalImage.dataUrl);
width = state.originalImage.width;
height = state.originalImage.height;
} else {
const dims = processor.getDimensions();
width = dims.width;
height = dims.height;
}
const aspectRatio = width / height;
const rs = state.resizeSettings;
widthSlider.max = width * 2;
heightSlider.max = height * 2;
let initialW = width;
let initialH = height;
if (
rs &&
Number.isFinite(rs.width) &&
Number.isFinite(rs.height) &&
rs.width > 0 &&
rs.height > 0
) {
initialW = rs.width;
initialH = rs.height;
}
// Clamp to slider ranges
initialW = Math.max(
parseInt(widthSlider.min, 10) || 10,
Math.min(initialW, parseInt(widthSlider.max, 10))
);
initialH = Math.max(
parseInt(heightSlider.min, 10) || 10,
Math.min(initialH, parseInt(heightSlider.max, 10))
);
widthSlider.value = initialW;
heightSlider.value = initialH;
widthValue.textContent = initialW;
heightValue.textContent = initialH;
zoomSlider.value = 1;
if (zoomValue) zoomValue.textContent = '100%';
paintWhiteToggle.checked = state.paintWhitePixels;
paintTransparentToggle.checked = state.paintTransparentPixels;
let _previewTimer = null;
let _previewJobId = 0;
let _isDraggingSize = false;
let _zoomLevel = 1;
let _ditherWorkBuf = null;
let _ditherEligibleBuf = null;
const ensureDitherBuffers = (n) => {
if (!_ditherWorkBuf || _ditherWorkBuf.length !== n * 3)
_ditherWorkBuf = new Float32Array(n * 3);
if (!_ditherEligibleBuf || _ditherEligibleBuf.length !== n)
_ditherEligibleBuf = new Uint8Array(n);
return { work: _ditherWorkBuf, eligible: _ditherEligibleBuf };
};
let _maskImageData = null;
let _maskData = null;
let _dirty = null;
const _resetDirty = () => {
_dirty = { minX: Infinity, minY: Infinity, maxX: -1, maxY: -1 };
};
const _markDirty = (x, y) => {
if (!_dirty) _resetDirty();
if (x < _dirty.minX) _dirty.minX = x;
if (y < _dirty.minY) _dirty.minY = y;
if (x > _dirty.maxX) _dirty.maxX = x;
if (y > _dirty.maxY) _dirty.maxY = y;
};
const _flushDirty = () => {
if (!_dirty || _dirty.maxX < _dirty.minX || _dirty.maxY < _dirty.minY) return;
const x = Math.max(0, _dirty.minX);
const y = Math.max(0, _dirty.minY);
const w = Math.min(maskCanvas.width - x, _dirty.maxX - x + 1);
const h = Math.min(maskCanvas.height - y, _dirty.maxY - y + 1);
if (w > 0 && h > 0) maskCtx.putImageData(_maskImageData, 0, 0, x, y, w, h);
_resetDirty();
};
const _ensureMaskOverlayBuffers = (w, h, rebuildFromMask = false) => {
if (!_maskImageData || _maskImageData.width !== w || _maskImageData.height !== h) {
_maskImageData = maskCtx.createImageData(w, h);
_maskData = _maskImageData.data;
rebuildFromMask = true;
}
if (rebuildFromMask) {
const m = state.resizeIgnoreMask;
const md = _maskData;
md.fill(0);
if (m) {
for (let i = 0; i < m.length; i++)
if (m[i]) {
const p = i * 4;
md[p] = 255;
md[p + 1] = 0;
md[p + 2] = 0;
md[p + 3] = 150;
}
}
maskCtx.putImageData(_maskImageData, 0, 0);
_resetDirty();
}
};
const ensureMaskSize = (w, h) => {
if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
state.resizeIgnoreMask = new Uint8Array(w * h);
}
baseCanvas.width = w;
baseCanvas.height = h;
maskCanvas.width = w;
maskCanvas.height = h;
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
// Ensure overlay buffers exist and rebuild from mask when dimensions change
_ensureMaskOverlayBuffers(w, h, true);
};
_updateResizePreview = async () => {
const jobId = ++_previewJobId;
const newWidth = parseInt(widthSlider.value, 10);
const newHeight = parseInt(heightSlider.value, 10);
_zoomLevel = parseFloat(zoomSlider.value);
widthValue.textContent = newWidth;
heightValue.textContent = newHeight;
ensureMaskSize(newWidth, newHeight);
canvasStack.style.width = newWidth + 'px';
canvasStack.style.height = newHeight + 'px';
baseCtx.imageSmoothingEnabled = false;
if (!state.availableColors || state.availableColors.length === 0) {
if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
await baseProcessor.load();
}
baseCtx.clearRect(0, 0, newWidth, newHeight);
baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
// Draw existing mask overlay buffer
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
updateZoomLayout();
return;
}
if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
await baseProcessor.load();
}
baseCtx.clearRect(0, 0, newWidth, newHeight);
baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
const imgData = baseCtx.getImageData(0, 0, newWidth, newHeight);
const data = imgData.data;
const tThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
const applyFSDither = () => {
const w = newWidth,
h = newHeight;
const n = w * h;
const { work, eligible } = ensureDitherBuffers(n);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const i4 = idx * 4;
const r = data[i4],
g = data[i4 + 1],
b = data[i4 + 2],
a = data[i4 + 3];
const isEligible =
(state.paintTransparentPixels || a >= tThresh) &&
(state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
eligible[idx] = isEligible ? 1 : 0;
work[idx * 3] = r;
work[idx * 3 + 1] = g;
work[idx * 3 + 2] = b;
if (!isEligible) {
data[i4 + 3] = 0; // transparent in preview overlay
}
}
}
const diffuse = (nx, ny, er, eg, eb, factor) => {
if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
const nidx = ny * w + nx;
if (!eligible[nidx]) return;
const base = nidx * 3;
work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
};
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
if (!eligible[idx]) continue;
const base = idx * 3;
const r0 = work[base],
g0 = work[base + 1],
b0 = work[base + 2];
const [nr, ng, nb] = Utils.findClosestPaletteColor(
r0,
g0,
b0,
state.activeColorPalette
);
const i4 = idx * 4;
data[i4] = nr;
data[i4 + 1] = ng;
data[i4 + 2] = nb;
data[i4 + 3] = 255;
const er = r0 - nr;
const eg = g0 - ng;
const eb = b0 - nb;
diffuse(x + 1, y, er, eg, eb, 7 / 16);
diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
diffuse(x, y + 1, er, eg, eb, 5 / 16);
diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
}
}
};
// Skip expensive dithering while user is dragging sliders
console.log(`🎨 Preview render - ditheringEnabled: ${state.ditheringEnabled}, _isDraggingSize: ${_isDraggingSize}`);
if (state.ditheringEnabled && !_isDraggingSize) {
console.log('✅ Applying dithering to preview');
applyFSDither();
} else {
console.log('⛔ Skipping dithering - applying direct color conversion');
for (let i = 0; i < data.length; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2],
a = data[i + 3];
if (
(!state.paintTransparentPixels && a < tThresh) ||
(!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
) {
data[i + 3] = 0;
continue;
}
const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
data[i] = nr;
data[i + 1] = ng;
data[i + 2] = nb;
data[i + 3] = 255;
}
}
if (jobId !== _previewJobId) return;
baseCtx.putImageData(imgData, 0, 0);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
updateZoomLayout();
};
const onWidthInput = () => {
if (keepAspect.checked) {
heightSlider.value = Math.round(parseInt(widthSlider.value, 10) / aspectRatio);
}
_updateResizePreview();
const curW = parseInt(widthSlider.value, 10);
const curH = parseInt(heightSlider.value, 10);
state.resizeSettings = {
baseWidth: width,
baseHeight: height,
width: curW,
height: curH,
};
saveBotSettings();
// Auto-fit after size changes
const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
};
const onHeightInput = () => {
if (keepAspect.checked) {
widthSlider.value = Math.round(parseInt(heightSlider.value, 10) * aspectRatio);
}
_updateResizePreview();
const curW = parseInt(widthSlider.value, 10);
const curH = parseInt(heightSlider.value, 10);
state.resizeSettings = {
baseWidth: width,
baseHeight: height,
width: curW,
height: curH,
};
saveBotSettings();
// Auto-fit after size changes
const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
};
paintWhiteToggle.onchange = (e) => {
state.paintWhitePixels = e.target.checked;
_updateResizePreview();
saveBotSettings();
};
paintTransparentToggle.onchange = (e) => {
state.paintTransparentPixels = e.target.checked;
_updateResizePreview();
saveBotSettings();
};
let panX = 0,
panY = 0;
const clampPan = () => {
const wrapRect = panStage?.getBoundingClientRect() || {
width: 0,
height: 0,
};
const w = (baseCanvas.width || 1) * _zoomLevel;
const h = (baseCanvas.height || 1) * _zoomLevel;
if (w <= wrapRect.width) {
panX = Math.floor((wrapRect.width - w) / 2);
} else {
const minX = wrapRect.width - w;
panX = Math.min(0, Math.max(minX, panX));
}
if (h <= wrapRect.height) {
panY = Math.floor((wrapRect.height - h) / 2);
} else {
const minY = wrapRect.height - h;
panY = Math.min(0, Math.max(minY, panY));
}
};
let _panRaf = 0;
const applyPan = () => {
if (_panRaf) return;
_panRaf = requestAnimationFrame(() => {
clampPan();
canvasStack.style.transform = `translate3d(${Math.round(
panX
)}px, ${Math.round(panY)}px, 0) scale(${_zoomLevel})`;
_panRaf = 0;
});
};
const updateZoomLayout = () => {
const w = baseCanvas.width || 1,
h = baseCanvas.height || 1;
baseCanvas.style.width = w + 'px';
baseCanvas.style.height = h + 'px';
maskCanvas.style.width = w + 'px';
maskCanvas.style.height = h + 'px';
canvasStack.style.width = w + 'px';
canvasStack.style.height = h + 'px';
applyPan();
};
const applyZoom = (z) => {
_zoomLevel = Math.max(0.05, Math.min(20, z || 1));
zoomSlider.value = _zoomLevel;
updateZoomLayout();
if (zoomValue) zoomValue.textContent = `${Math.round(_zoomLevel * 100)}%`;
};
zoomSlider.addEventListener('input', () => {
applyZoom(parseFloat(zoomSlider.value));
});
if (zoomInBtn)
zoomInBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) + 0.1));
if (zoomOutBtn)
zoomOutBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) - 0.1));
const computeFitZoom = () => {
const wrapRect = panStage?.getBoundingClientRect();
if (!wrapRect) return 1;
const w = baseCanvas.width || 1;
const h = baseCanvas.height || 1;
const margin = 10;
const scaleX = (wrapRect.width - margin) / w;
const scaleY = (wrapRect.height - margin) / h;
return Math.max(0.05, Math.min(20, Math.min(scaleX, scaleY)));
};
if (zoomFitBtn)
zoomFitBtn.addEventListener('click', () => {
applyZoom(computeFitZoom());
centerInView();
});
if (zoomActualBtn)
zoomActualBtn.addEventListener('click', () => {
applyZoom(1);
centerInView();
});
const centerInView = () => {
if (!panStage) return;
const rect = panStage.getBoundingClientRect();
const w = (baseCanvas.width || 1) * _zoomLevel;
const h = (baseCanvas.height || 1) * _zoomLevel;
panX = Math.floor((rect.width - w) / 2);
panY = Math.floor((rect.height - h) / 2);
applyPan();
};
let isPanning = false;
let startX = 0,
startY = 0,
startPanX = 0,
startPanY = 0;
let allowPan = false; // Space key
let panMode = false; // Explicit pan mode toggle for touch/one-button mice
const isPanMouseButton = (e) => e.button === 1 || e.button === 2;
const setCursor = (val) => {
if (panStage) panStage.style.cursor = val;
};
const isPanActive = (e) => panMode || allowPan || isPanMouseButton(e);
const updatePanModeBtn = () => {
if (!panModeBtn) return;
panModeBtn.classList.toggle('active', panMode);
panModeBtn.setAttribute('aria-pressed', panMode ? 'true' : 'false');
};
if (panModeBtn) {
updatePanModeBtn();
panModeBtn.addEventListener('click', () => {
panMode = !panMode;
updatePanModeBtn();
setCursor(panMode ? 'grab' : '');
});
}
if (panStage) {
panStage.addEventListener('contextmenu', (e) => {
if (allowPan) e.preventDefault();
});
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
allowPan = true;
setCursor('grab');
}
});
window.addEventListener('keyup', (e) => {
if (e.code === 'Space') {
allowPan = false;
if (!isPanning) setCursor('');
}
});
panStage.addEventListener('mousedown', (e) => {
if (!isPanActive(e)) return;
e.preventDefault();
isPanning = true;
startX = e.clientX;
startY = e.clientY;
startPanX = panX;
startPanY = panY;
setCursor('grabbing');
});
window.addEventListener('mousemove', (e) => {
if (!isPanning) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
panX = startPanX + dx;
panY = startPanY + dy;
applyPan();
});
window.addEventListener('mouseup', () => {
if (isPanning) {
isPanning = false;
setCursor(allowPan ? 'grab' : '');
}
});
panStage.addEventListener(
'wheel',
(e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const rect = panStage.getBoundingClientRect();
const cx = e.clientX - rect.left - panX;
const cy = e.clientY - rect.top - panY;
const before = _zoomLevel;
const step = Math.max(0.05, Math.min(0.5, Math.abs(e.deltaY) > 20 ? 0.2 : 0.1));
const next = Math.max(0.05, Math.min(20, before + (e.deltaY > 0 ? -step : step)));
if (next === before) return;
const scale = next / before;
panX = panX - cx * (scale - 1);
panY = panY - cy * (scale - 1);
applyZoom(next);
},
{ passive: false }
);
let lastTouchDist = null;
let touchStartTime = 0;
let doubleTapTimer = null;
panStage.addEventListener(
'touchstart',
(e) => {
if (e.touches.length === 1) {
const t = e.touches[0];
isPanning = true;
startX = t.clientX;
startY = t.clientY;
startPanX = panX;
startPanY = panY;
setCursor('grabbing');
const now = Date.now();
if (now - touchStartTime < 300) {
// double tap -> toggle 100%/fit
const z = Math.abs(_zoomLevel - 1) < 0.01 ? computeFitZoom() : 1;
applyZoom(z);
centerInView();
if (doubleTapTimer) clearTimeout(doubleTapTimer);
} else {
touchStartTime = now;
doubleTapTimer = setTimeout(() => {
doubleTapTimer = null;
}, 320);
}
} else if (e.touches.length === 2) {
// Pinch start
const [a, b] = e.touches;
lastTouchDist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
}
},
{ passive: true }
);
panStage.addEventListener(
'touchmove',
(e) => {
if (e.touches.length === 1 && isPanning) {
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
panX = startPanX + dx;
panY = startPanY + dy;
applyPan();
} else if (e.touches.length === 2 && lastTouchDist != null) {
e.preventDefault();
const [a, b] = e.touches;
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
const rect = panStage.getBoundingClientRect();
const centerX = (a.clientX + b.clientX) / 2 - rect.left - panX;
const centerY = (a.clientY + b.clientY) / 2 - rect.top - panY;
const before = _zoomLevel;
const scale = dist / (lastTouchDist || dist);
const next = Math.max(0.05, Math.min(20, before * scale));
if (next !== before) {
panX = panX - centerX * (next / before - 1);
panY = panY - centerY * (next / before - 1);
applyZoom(next);
}
lastTouchDist = dist;
}
},
{ passive: false }
);
panStage.addEventListener('touchend', () => {
isPanning = false;
lastTouchDist = null;
setCursor(panMode || allowPan ? 'grab' : '');
});
}
const schedulePreview = () => {
if (_previewTimer) clearTimeout(_previewTimer);
const run = () => {
_previewTimer = null;
_updateResizePreview();
};
if (window.requestIdleCallback) {
_previewTimer = setTimeout(() => requestIdleCallback(run, { timeout: 150 }), 50);
} else {
_previewTimer = setTimeout(() => requestAnimationFrame(run), 50);
}
};
// Track dragging to reduce work and skip dithering during drag
const markDragStart = () => {
_isDraggingSize = true;
};
const markDragEnd = () => {
_isDraggingSize = false;
schedulePreview();
};
widthSlider.addEventListener('pointerdown', markDragStart);
heightSlider.addEventListener('pointerdown', markDragStart);
widthSlider.addEventListener('pointerup', markDragEnd);
heightSlider.addEventListener('pointerup', markDragEnd);
widthSlider.addEventListener('input', () => {
onWidthInput();
schedulePreview();
});
heightSlider.addEventListener('input', () => {
onHeightInput();
schedulePreview();
});
// Mask painting UX: brush size, modes, row/column fills, and precise coords
let draggingMask = false;
let lastPaintX = -1,
lastPaintY = -1;
let brushSize = 1;
let rowColSize = 1;
let maskMode = 'ignore'; // 'ignore' | 'unignore' | 'toggle'
const brushEl = resizeContainer.querySelector('#maskBrushSize');
const brushValEl = resizeContainer.querySelector('#maskBrushSizeValue');
const btnIgnore = resizeContainer.querySelector('#maskModeIgnore');
const btnUnignore = resizeContainer.querySelector('#maskModeUnignore');
const btnToggle = resizeContainer.querySelector('#maskModeToggle');
const clearIgnoredBtnEl = resizeContainer.querySelector('#clearIgnoredBtn');
const invertMaskBtn = resizeContainer.querySelector('#invertMaskBtn');
const rowColSizeEl = resizeContainer.querySelector('#rowColSize');
const rowColSizeValEl = resizeContainer.querySelector('#rowColSizeValue');
const updateModeButtons = () => {
const map = [
[btnIgnore, 'ignore'],
[btnUnignore, 'unignore'],
[btnToggle, 'toggle'],
];
for (const [el, m] of map) {
if (!el) continue;
const active = maskMode === m;
el.classList.toggle('active', active);
el.setAttribute('aria-pressed', active ? 'true' : 'false');
}
};
const setMode = (mode) => {
maskMode = mode;
updateModeButtons();
};
if (brushEl && brushValEl) {
brushEl.addEventListener('input', () => {
brushSize = parseInt(brushEl.value, 10) || 1;
brushValEl.textContent = brushSize;
});
brushValEl.textContent = brushEl.value;
brushSize = parseInt(brushEl.value, 10) || 1;
}
if (rowColSizeEl && rowColSizeValEl) {
rowColSizeEl.addEventListener('input', () => {
rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
rowColSizeValEl.textContent = rowColSize;
});
rowColSizeValEl.textContent = rowColSizeEl.value;
rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
}
if (btnIgnore) btnIgnore.addEventListener('click', () => setMode('ignore'));
if (btnUnignore) btnUnignore.addEventListener('click', () => setMode('unignore'));
if (btnToggle) btnToggle.addEventListener('click', () => setMode('toggle'));
// Initialize button state (default to toggle mode)
updateModeButtons();
const mapClientToPixel = (clientX, clientY) => {
// Compute without rounding until final step to avoid drift at higher zoom
const rect = baseCanvas.getBoundingClientRect();
const scaleX = rect.width / baseCanvas.width;
const scaleY = rect.height / baseCanvas.height;
const dx = (clientX - rect.left) / scaleX;
const dy = (clientY - rect.top) / scaleY;
const x = Math.floor(dx);
const y = Math.floor(dy);
return { x, y };
};
const ensureMask = (w, h) => {
if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
state.resizeIgnoreMask = new Uint8Array(w * h);
}
};
const paintCircle = (cx, cy, radius, value) => {
const w = baseCanvas.width,
h = baseCanvas.height;
ensureMask(w, h);
const r2 = radius * radius;
for (let yy = cy - radius; yy <= cy + radius; yy++) {
if (yy < 0 || yy >= h) continue;
for (let xx = cx - radius; xx <= cx + radius; xx++) {
if (xx < 0 || xx >= w) continue;
const dx = xx - cx,
dy = yy - cy;
if (dx * dx + dy * dy <= r2) {
const idx = yy * w + xx;
let val = state.resizeIgnoreMask[idx];
if (maskMode === 'toggle') {
val = val ? 0 : 1;
} else if (maskMode === 'ignore') {
val = 1;
} else {
val = 0;
}
state.resizeIgnoreMask[idx] = val;
if (_maskData) {
const p = idx * 4;
if (val) {
_maskData[p] = 255;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 150;
} else {
_maskData[p] = 0;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 0;
}
_markDirty(xx, yy);
}
}
}
}
};
const paintRow = (y, value) => {
const w = baseCanvas.width,
h = baseCanvas.height;
ensureMask(w, h);
if (y < 0 || y >= h) return;
// Paint multiple rows based on rowColSize
const halfSize = Math.floor(rowColSize / 2);
const startY = Math.max(0, y - halfSize);
const endY = Math.min(h - 1, y + halfSize);
for (let rowY = startY; rowY <= endY; rowY++) {
for (let x = 0; x < w; x++) {
const idx = rowY * w + x;
let val = state.resizeIgnoreMask[idx];
if (maskMode === 'toggle') {
val = val ? 0 : 1;
} else if (maskMode === 'ignore') {
val = 1;
} else {
val = 0;
}
state.resizeIgnoreMask[idx] = val;
if (_maskData) {
const p = idx * 4;
if (val) {
_maskData[p] = 255;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 150;
} else {
_maskData[p] = 0;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 0;
}
}
}
if (_maskData) {
_markDirty(0, rowY);
_markDirty(w - 1, rowY);
}
}
};
const paintColumn = (x, value) => {
const w = baseCanvas.width,
h = baseCanvas.height;
ensureMask(w, h);
if (x < 0 || x >= w) return;
// Paint multiple columns based on rowColSize
const halfSize = Math.floor(rowColSize / 2);
const startX = Math.max(0, x - halfSize);
const endX = Math.min(w - 1, x + halfSize);
for (let colX = startX; colX <= endX; colX++) {
for (let y = 0; y < h; y++) {
const idx = y * w + colX;
let val = state.resizeIgnoreMask[idx];
if (maskMode === 'toggle') {
val = val ? 0 : 1;
} else if (maskMode === 'ignore') {
val = 1;
} else {
val = 0;
}
state.resizeIgnoreMask[idx] = val;
if (_maskData) {
const p = idx * 4;
if (val) {
_maskData[p] = 255;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 150;
} else {
_maskData[p] = 0;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 0;
}
}
}
if (_maskData) {
_markDirty(colX, 0);
_markDirty(colX, h - 1);
}
}
};
const redrawMaskOverlay = () => {
// Only flush the dirty region; full rebuild happens on size change
_flushDirty();
};
const handlePaint = (e) => {
// Suppress painting while panning
if ((e.buttons & 4) === 4 || (e.buttons & 2) === 2 || allowPan) return;
const { x, y } = mapClientToPixel(e.clientX, e.clientY);
const w = baseCanvas.width,
h = baseCanvas.height;
if (x < 0 || y < 0 || x >= w || y >= h) return;
const radius = Math.max(1, Math.floor(brushSize / 2));
if (e.shiftKey) {
paintRow(y);
} else if (e.altKey) {
paintColumn(x);
} else {
paintCircle(x, y, radius);
}
lastPaintX = x;
lastPaintY = y;
redrawMaskOverlay();
};
maskCanvas.addEventListener('mousedown', (e) => {
if (e.button === 1 || e.button === 2 || allowPan) return; // let pan handler manage
draggingMask = true;
handlePaint(e);
});
// Avoid hijacking touch gestures for panning/zooming
maskCanvas.addEventListener(
'touchstart',
(e) => {
/* let panStage handle */
},
{ passive: true }
);
maskCanvas.addEventListener(
'touchmove',
(e) => {
/* let panStage handle */
},
{ passive: true }
);
maskCanvas.addEventListener(
'touchend',
(e) => {
/* let panStage handle */
},
{ passive: true }
);
window.addEventListener('mousemove', (e) => {
if (draggingMask) handlePaint(e);
});
window.addEventListener('mouseup', () => {
if (draggingMask) {
draggingMask = false;
saveBotSettings();
}
});
if (clearIgnoredBtnEl)
clearIgnoredBtnEl.addEventListener('click', () => {
const w = baseCanvas.width,
h = baseCanvas.height;
if (state.resizeIgnoreMask) state.resizeIgnoreMask.fill(0);
_ensureMaskOverlayBuffers(w, h, true);
_updateResizePreview();
saveBotSettings();
});
if (invertMaskBtn)
invertMaskBtn.addEventListener('click', () => {
if (!state.resizeIgnoreMask) return;
for (let i = 0; i < state.resizeIgnoreMask.length; i++)
state.resizeIgnoreMask[i] = state.resizeIgnoreMask[i] ? 0 : 1;
const w = baseCanvas.width,
h = baseCanvas.height;
_ensureMaskOverlayBuffers(w, h, true);
_updateResizePreview();
saveBotSettings();
});
confirmResize.onclick = async () => {
const newWidth = parseInt(widthSlider.value, 10);
const newHeight = parseInt(heightSlider.value, 10);
// Generate the final paletted image data
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
tempCtx.imageSmoothingEnabled = false;
if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
await baseProcessor.load();
}
tempCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
const imgData = tempCtx.getImageData(0, 0, newWidth, newHeight);
const data = imgData.data;
const tThresh2 = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
let totalValidPixels = 0;
const mask =
state.resizeIgnoreMask && state.resizeIgnoreMask.length === newWidth * newHeight
? state.resizeIgnoreMask
: null;
const applyFSDitherFinal = async () => {
const w = newWidth,
h = newHeight;
const n = w * h;
const { work, eligible } = ensureDitherBuffers(n);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const i4 = idx * 4;
const r = data[i4],
g = data[i4 + 1],
b = data[i4 + 2],
a = data[i4 + 3];
const masked = mask && mask[idx];
const isEligible =
!masked &&
(state.paintTransparentPixels || a >= tThresh2) &&
(state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
eligible[idx] = isEligible ? 1 : 0;
work[idx * 3] = r;
work[idx * 3 + 1] = g;
work[idx * 3 + 2] = b;
if (!isEligible) {
data[i4 + 3] = 0;
}
}
// Yield to keep UI responsive
if ((y & 15) === 0) await Promise.resolve();
}
const diffuse = (nx, ny, er, eg, eb, factor) => {
if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
const nidx = ny * w + nx;
if (!eligible[nidx]) return;
const base = nidx * 3;
work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
};
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
if (!eligible[idx]) continue;
const base = idx * 3;
const r0 = work[base],
g0 = work[base + 1],
b0 = work[base + 2];
const [nr, ng, nb] = Utils.findClosestPaletteColor(
r0,
g0,
b0,
state.activeColorPalette
);
const i4 = idx * 4;
data[i4] = nr;
data[i4 + 1] = ng;
data[i4 + 2] = nb;
data[i4 + 3] = 255;
totalValidPixels++;
const er = r0 - nr;
const eg = g0 - ng;
const eb = b0 - nb;
diffuse(x + 1, y, er, eg, eb, 7 / 16);
diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
diffuse(x, y + 1, er, eg, eb, 5 / 16);
diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
}
// Yield every row to reduce jank
await Promise.resolve();
}
};
if (state.ditheringEnabled) {
await applyFSDitherFinal();
} else {
for (let i = 0; i < data.length; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2],
a = data[i + 3];
const masked = mask && mask[i >> 2];
const isTransparent = (!state.paintTransparentPixels && a < tThresh2) || masked;
const isWhiteAndSkipped = !state.paintWhitePixels && Utils.isWhitePixel(r, g, b);
if (isTransparent || isWhiteAndSkipped) {
data[i + 3] = 0; // overlay transparency
continue;
}
totalValidPixels++;
const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
data[i] = nr;
data[i + 1] = ng;
data[i + 2] = nb;
data[i + 3] = 255;
}
}
tempCtx.putImageData(imgData, 0, 0);
// Save the final pixel data for painting
// Persist the paletted (and possibly dithered) pixels so painting uses the same output seen in overlay
const palettedPixels = new Uint8ClampedArray(imgData.data);
state.imageData.pixels = palettedPixels;
state.imageData.width = newWidth;
state.imageData.height = newHeight;
state.imageData.totalPixels = totalValidPixels;
state.totalPixels = totalValidPixels;
state.paintedPixels = 0;
state.resizeSettings = {
baseWidth: width,
baseHeight: height,
width: newWidth,
height: newHeight,
};
saveBotSettings();
const finalImageBitmap = await createImageBitmap(tempCanvas);
await overlayManager.setImage(finalImageBitmap);
overlayManager.enable();
toggleOverlayBtn.classList.add('active');
toggleOverlayBtn.setAttribute('aria-pressed', 'true');
// Keep state.imageData.processor as the original-based source; painting uses paletted pixels already stored
await updateStats();
updateUI('resizeSuccess', 'success', {
width: newWidth,
height: newHeight,
});
// ✅ Mark image as processed and enable position selection
state.imageProcessed = true;
selectPosBtn.disabled = false;
selectPosBtn.title = Utils.t('selectPosition') || 'Select starting position on canvas';
// Enable start button if position is already set
if (state.startPosition) {
startBtn.disabled = false;
}
closeResizeDialog();
};
downloadPreviewBtn.onclick = () => {
try {
const w = baseCanvas.width,
h = baseCanvas.height;
const out = document.createElement('canvas');
out.width = w;
out.height = h;
const octx = out.getContext('2d');
octx.imageSmoothingEnabled = false;
octx.drawImage(baseCanvas, 0, 0);
octx.drawImage(maskCanvas, 0, 0);
const link = document.createElement('a');
link.download = 'wplace-preview.png';
link.href = out.toDataURL();
link.click();
} catch (e) {
console.warn('Failed to download preview:', e);
}
};
cancelResize.onclick = closeResizeDialog;
editImageBtn.onclick = () => {
showEditPanel();
};
resizeOverlay.style.display = 'block';
resizeContainer.style.display = 'block';
// Reinitialize color palette with current available colors
initializeColorPalette(resizeContainer);
_updateResizePreview();
_resizeDialogCleanup = () => {
try {
zoomSlider.replaceWith(zoomSlider.cloneNode(true));
} catch { }
try {
if (zoomInBtn) zoomInBtn.replaceWith(zoomInBtn.cloneNode(true));
} catch { }
try {
if (zoomOutBtn) zoomOutBtn.replaceWith(zoomOutBtn.cloneNode(true));
} catch { }
};
setTimeout(() => {
if (typeof computeFitZoom === 'function') {
const z = computeFitZoom();
if (!isNaN(z) && isFinite(z)) {
applyZoom(z);
centerInView();
}
} else {
centerInView();
}
}, 0);
}
function closeResizeDialog() {
try {
if (typeof _resizeDialogCleanup === 'function') {
_resizeDialogCleanup();
}
} catch { }
resizeOverlay.style.display = 'none';
resizeContainer.style.display = 'none';
_updateResizePreview = () => { };
try {
if (typeof cancelAnimationFrame === 'function' && _panRaf) {
cancelAnimationFrame(_panRaf);
}
} catch { }
try {
if (_previewTimer) {
clearTimeout(_previewTimer);
_previewTimer = null;
}
} catch { }
_maskImageData = null;
_maskData = null;
_dirty = null;
_ditherWorkBuf = null;
_ditherEligibleBuf = null;
_resizeDialogCleanup = null;
}
function showEditPanel() {
try {
// Validate that we have a valid base canvas
if (!baseCanvas || !baseCanvas.width || !baseCanvas.height) {
Utils.showAlert('No image available for editing. Please upload an image first.', 'error');
return;
}
// Hide resize panel
resizeContainer.style.display = 'none';
// Create edit panel if it doesn't exist
let editOverlay = document.getElementById('editOverlay');
if (!editOverlay) {
createEditPanel();
editOverlay = document.getElementById('editOverlay');
}
// Get current image data from baseCanvas
const imageData = baseCanvas.toDataURL();
// Initialize edit panel with current image
initializeEditPanel(imageData);
// Show edit panel
editOverlay.style.display = 'block';
console.log('✨ Pixel Art Editor opened successfully');
} catch (error) {
console.error('Error opening pixel art editor:', error);
Utils.showAlert('Failed to open pixel art editor. Please try again.', 'error');
}
}
function createEditPanel() {
const editOverlay = document.createElement('div');
editOverlay.id = 'editOverlay';
editOverlay.className = 'edit-overlay';
editOverlay.innerHTML = `
Manual Pixel Art Editor
🖱️ Left click: Draw | Right click/Ctrl+drag: Pan | Mouse wheel: Zoom
1
Navigator
Position: (0, 0) | Color: #000000 | Zoom: 100%
`;
document.body.appendChild(editOverlay);
// Set up event handlers
setupEditPanelEvents();
}
function setupEditPanelEvents() {
const editBackBtn = document.getElementById('editBackBtn');
const editApplyBtn = document.getElementById('editApplyBtn');
const paintBrush = document.getElementById('paintBrush');
const eraseTool = document.getElementById('eraseTool');
const eyedropperTool = document.getElementById('eyedropperTool');
const fillTool = document.getElementById('fillTool');
const brushSize = document.getElementById('brushSize');
const brushSizeValue = document.getElementById('brushSizeValue');
const showGrid = document.getElementById('showGrid');
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
const resetViewBtn = document.getElementById('resetViewBtn');
const editZoomIn = document.getElementById('editZoomIn');
const editZoomOut = document.getElementById('editZoomOut');
const zoomSelect = document.getElementById('zoomSelect');
const zoomFit = document.getElementById('zoomFit');
const zoom100 = document.getElementById('zoom100');
const minimapCanvas = document.getElementById('minimapCanvas');
// Back to resize panel
editBackBtn.onclick = () => {
document.getElementById('editOverlay').style.display = 'none';
resizeContainer.style.display = 'block';
};
// Apply changes
editApplyBtn.onclick = () => {
applyEditChanges();
document.getElementById('editOverlay').style.display = 'none';
resizeContainer.style.display = 'block';
};
// Tool selection
paintBrush.onclick = () => {
selectTool('paint');
};
eraseTool.onclick = () => {
selectTool('erase');
};
eyedropperTool.onclick = () => {
selectTool('eyedropper');
};
fillTool.onclick = () => {
selectTool('fill');
};
// Grid toggle
showGrid.onclick = () => {
editState.showGrid = !editState.showGrid;
showGrid.classList.toggle('active', editState.showGrid);
redrawCanvas();
};
// Brush size
brushSize.oninput = () => {
brushSizeValue.textContent = brushSize.value;
updateBrushSize(parseInt(brushSize.value));
};
// Undo/Redo
undoBtn.onclick = () => undoEdit();
redoBtn.onclick = () => redoEdit();
// Reset view
resetViewBtn.onclick = () => resetEditView();
// Enhanced zoom controls
editZoomIn.onclick = () => zoomIn();
editZoomOut.onclick = () => zoomOut();
zoomSelect.onchange = () => {
const newZoom = parseFloat(zoomSelect.value);
setZoom(newZoom);
};
zoomFit.onclick = () => fitToWindow();
zoom100.onclick = () => setZoom(1);
// Minimap navigation
if (minimapCanvas) {
minimapCanvas.onclick = (e) => navigateToMinimapPosition(e);
}
}
const ZOOM_LEVELS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 32];
let editState = {
currentTool: 'paint',
currentColor: '#000000',
currentColorId: null,
brushSize: 1,
zoom: 1,
panX: 0,
panY: 0,
undoStack: [],
redoStack: [],
isDrawing: false,
isPanning: false,
showGrid: false,
mouseX: 0,
mouseY: 0,
canvasWidth: 0,
canvasHeight: 0,
updatePending: false,
lastPaintPos: null,
touchStart: null,
lastTouchDistance: 0,
lastTouchCenter: { x: 0, y: 0 }
};
function calculateOptimalPanelSize(imageWidth, imageHeight) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Reserve minimal space for UI elements for almost fullscreen experience
const uiReserved = {
header: 80,
toolbar: 60,
bottomBar: 120,
padding: 40
};
const maxCanvasWidth = viewportWidth - uiReserved.padding;
const maxCanvasHeight = viewportHeight - uiReserved.header - uiReserved.toolbar - uiReserved.bottomBar - uiReserved.padding;
// Calculate optimal initial zoom to fit image
const scaleX = maxCanvasWidth / imageWidth;
const scaleY = maxCanvasHeight / imageHeight;
const initialZoom = Math.min(scaleX, scaleY, 1); // Don't zoom in initially
return {
panelWidth: Math.min(viewportWidth * 0.95, imageWidth * initialZoom + uiReserved.colorPanel + uiReserved.padding),
panelHeight: Math.min(viewportHeight * 0.95, imageHeight * initialZoom + uiReserved.toolbar + uiReserved.statusBar + uiReserved.canvasToolbar + uiReserved.padding),
canvasWidth: Math.min(maxCanvasWidth, imageWidth * initialZoom),
canvasHeight: Math.min(maxCanvasHeight, imageHeight * initialZoom),
initialZoom: Math.max(0.1, initialZoom)
};
}
function initializeEditPanel(imageData) {
const editCanvas = document.getElementById('editCanvas');
const ctx = editCanvas.getContext('2d');
// Reset edit state
editState.zoom = 1;
editState.panX = 0;
editState.panY = 0;
editState.undoStack = [];
editState.redoStack = [];
editState.currentTool = 'paint';
editState.brushSize = 1;
editState.showGrid = false;
editState.isPanning = false;
editState.isDrawing = false;
editState.lastPaintPos = null;
// Set canvas size to match baseCanvas
editCanvas.width = baseCanvas.width;
editCanvas.height = baseCanvas.height;
editState.canvasWidth = editCanvas.width;
editState.canvasHeight = editCanvas.height;
// Configure canvas context for pixel art
ctx.imageSmoothingEnabled = false;
ctx.webkitImageSmoothingEnabled = false;
ctx.mozImageSmoothingEnabled = false;
ctx.msImageSmoothingEnabled = false;
// Calculate optimal panel size
const panelSize = calculateOptimalPanelSize(editCanvas.width, editCanvas.height);
const editContainer = document.querySelector('.edit-container');
if (editContainer) {
editContainer.style.width = panelSize.panelWidth + 'px';
editContainer.style.height = panelSize.panelHeight + 'px';
}
// Setup canvas container
setupCanvasContainer();
// Add CSS checkerboard background to the wrapper element (not the canvas)
// This allows users to see transparency without saving it to the template
const canvasWrapper = document.getElementById('editCanvasWrapper');
if (canvasWrapper) {
canvasWrapper.style.background = `
repeating-conic-gradient(#f0f0f0 0% 25%, #e0e0e0 0% 50%)
50% / 16px 16px
`;
}
// Load image onto canvas
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
// REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
ctx.drawImage(img, 0, 0);
// Always fit artwork to the visible area on start and center
fitToWindow();
centerCanvas();
// Setup minimap
setupMinimap();
// Save initial state for undo
saveEditState();
// Set up canvas drawing events
setupCanvasDrawing();
// Initialize color palette
initializeEditColorPalette();
// Setup keyboard shortcuts
setupKeyboardShortcuts();
// Setup touch support
setupTouchSupport();
// Set initial tool
selectTool('paint');
// Update status bar
updateStatusBar(0, 0);
// Center canvas initially
centerCanvas();
};
img.src = imageData;
}
// mapClientToCanvas function for coordinate mapping
function mapClientToCanvas(clientX, clientY) {
const canvas = document.getElementById('editCanvas');
const wrapper = document.getElementById('editCanvasWrapper');
const container = document.getElementById('editCanvasContainer');
if (!canvas || !wrapper || !container) return null;
// Get the actual canvas element bounds (after CSS transform)
const canvasRect = canvas.getBoundingClientRect();
// Calculate relative position within the actual canvas bounds
const relativeX = (clientX - canvasRect.left) / canvasRect.width * canvas.width;
const relativeY = (clientY - canvasRect.top) / canvasRect.height * canvas.height;
// Convert to canvas coordinates
const canvasX = Math.floor(relativeX);
const canvasY = Math.floor(relativeY);
// Bounds checking
if (canvasX < 0 || canvasX >= canvas.width || canvasY < 0 || canvasY >= canvas.height) {
return null;
}
return { x: canvasX, y: canvasY };
}
function setupCanvasDrawing() {
const editCanvas = document.getElementById('editCanvas');
const ctx = editCanvas.getContext('2d');
let isDrawing = false;
let isPanning = false;
let lastX = 0;
let lastY = 0;
let panStartX = 0;
let panStartY = 0;
const getMousePos = (e) => {
return mapClientToCanvas(e.clientX, e.clientY);
};
editCanvas.onmousedown = (e) => {
if (e.button === 2 || e.ctrlKey) { // Right click or Ctrl+click for panning
editState.isPanning = true;
panStartX = e.clientX - editState.panX;
panStartY = e.clientY - editState.panY;
editCanvas.style.cursor = 'move';
e.preventDefault();
return;
}
const pos = getMousePos(e);
if (!pos) return;
if (editState.currentTool === 'eyedropper') {
handleEyedropper(pos.x, pos.y);
return;
}
if (editState.currentTool === 'fill') {
floodFill(pos.x, pos.y, editState.currentColor);
saveEditState();
return;
}
editState.isDrawing = true;
editState.lastPaintPos = { x: pos.x, y: pos.y };
lastX = pos.x;
lastY = pos.y;
paintAtPosition(pos.x, pos.y, true);
};
editCanvas.onmousemove = (e) => {
const pos = getMousePos(e);
if (pos) {
editState.mouseX = pos.x;
editState.mouseY = pos.y;
updateStatusBar(pos.x, pos.y);
}
if (editState.isPanning) {
editState.panX = e.clientX - panStartX;
editState.panY = e.clientY - panStartY;
constrainPan();
updateCanvasTransform();
updateMinimap();
return;
}
if (!editState.isDrawing) {
return;
}
if (pos && editState.lastPaintPos) {
paintAtPosition(pos.x, pos.y, true);
editState.lastPaintPos = { x: pos.x, y: pos.y };
}
};
editCanvas.onmouseup = (e) => {
if (editState.isPanning) {
editState.isPanning = false;
selectTool(editState.currentTool); // Restore cursor
return;
}
if (editState.isDrawing) {
editState.isDrawing = false;
editState.lastPaintPos = null;
saveEditState();
}
};
editCanvas.onmouseleave = () => {
if (editState.isPanning) {
editState.isPanning = false;
selectTool(editState.currentTool); // Restore cursor
}
if (editState.isDrawing) {
editState.isDrawing = false;
editState.lastPaintPos = null;
saveEditState();
}
};
// Prevent context menu on right click
editCanvas.oncontextmenu = (e) => {
e.preventDefault();
return false;
};
// Enhanced zoom with mouse wheel (zoom to cursor)
editCanvas.onwheel = (e) => {
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
zoomToPoint(editState.zoom * zoomFactor, e.clientX, e.clientY);
};
}
function paintAtPosition(x, y, isMouseDown = true) {
if (!isMouseDown || !editState.currentColor) return;
const canvas = document.getElementById('editCanvas');
const ctx = canvas.getContext('2d');
if (editState.lastPaintPos && editState.currentTool === 'paint') {
// Draw line from last position to current position
drawLine(ctx, editState.lastPaintPos.x, editState.lastPaintPos.y, x, y, editState.currentTool);
} else {
// Single brush stroke
drawBrush(ctx, x, y, editState.currentTool);
}
editState.lastPaintPos = { x, y };
// Update minimap thumbnail
if (!editState.updatePending) {
editState.updatePending = true;
requestAnimationFrame(() => {
setupMinimap();
editState.updatePending = false;
});
}
}
function drawBrush(ctx, x, y, tool) {
const size = editState.brushSize;
const halfSize = Math.floor(size / 2);
if (tool === 'paint') {
ctx.fillStyle = editState.currentColor;
for (let dx = 0; dx < size; dx++) {
for (let dy = 0; dy < size; dy++) {
// Center the brush at the cursor position
const px = x - halfSize + dx;
const py = y - halfSize + dy;
if (px >= 0 && px < ctx.canvas.width && py >= 0 && py < ctx.canvas.height) {
ctx.fillRect(px, py, 1, 1);
}
}
}
} else if (tool === 'erase') {
for (let dx = 0; dx < size; dx++) {
for (let dy = 0; dy < size; dy++) {
// Center the brush at the cursor position
const px = x - halfSize + dx;
const py = y - halfSize + dy;
if (px >= 0 && px < ctx.canvas.width && py >= 0 && py < ctx.canvas.height) {
ctx.clearRect(px, py, 1, 1);
}
}
}
}
}
function drawPixel(x, y) {
drawBrush(document.getElementById('editCanvas').getContext('2d'), x, y, 'paint');
}
function erasePixel(x, y) {
const editCanvas = document.getElementById('editCanvas');
const ctx = editCanvas.getContext('2d');
const size = editState.brushSize;
const halfSize = Math.floor(size / 2);
for (let dx = 0; dx < size; dx++) {
for (let dy = 0; dy < size; dy++) {
// Center the brush at the cursor position (consistent with drawBrush)
const px = x - halfSize + dx;
const py = y - halfSize + dy;
if (px >= 0 && px < editCanvas.width && py >= 0 && py < editCanvas.height) {
ctx.clearRect(px, py, 1, 1);
}
}
}
}
function drawLine(ctx, x1, y1, x2, y2, tool) {
// Bresenham's line algorithm with tool support
const dx = Math.abs(x2 - x1);
const dy = Math.abs(y2 - y1);
const sx = x1 < x2 ? 1 : -1;
const sy = y1 < y2 ? 1 : -1;
let err = dx - dy;
let x = x1;
let y = y1;
while (true) {
drawBrush(ctx, x, y, tool);
if (x === x2 && y === y2) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
}
function eraseLine(x1, y1, x2, y2) {
// Same as drawLine but with erase
const dx = Math.abs(x2 - x1);
const dy = Math.abs(y2 - y1);
const sx = x1 < x2 ? 1 : -1;
const sy = y1 < y2 ? 1 : -1;
let err = dx - dy;
let x = x1;
let y = y1;
while (true) {
erasePixel(x, y);
if (x === x2 && y === y2) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
}
function selectTool(tool) {
editState.currentTool = tool;
document.querySelectorAll('.edit-tool').forEach(btn => {
btn.classList.remove('active');
});
const editCanvas = document.getElementById('editCanvas');
switch (tool) {
case 'paint':
document.getElementById('paintBrush').classList.add('active');
editCanvas.style.cursor = 'crosshair';
break;
case 'erase':
document.getElementById('eraseTool').classList.add('active');
editCanvas.style.cursor = 'not-allowed';
break;
case 'eyedropper':
document.getElementById('eyedropperTool').classList.add('active');
editCanvas.style.cursor = 'copy';
break;
case 'fill':
document.getElementById('fillTool').classList.add('active');
editCanvas.style.cursor = 'pointer';
break;
}
}
function updateBrushSize(size) {
editState.brushSize = size;
}
function initializeEditColorPalette() {
const colorGrid = document.getElementById('editColorGrid');
const currentColorDisplay = document.getElementById('currentColorDisplay');
colorGrid.innerHTML = '';
let availableColors = [];
// Try to get colors from state first
if (state && state.availableColors && state.availableColors.length > 0) {
availableColors = state.availableColors.map(color => ({
id: color.id,
name: color.name,
rgb: color.rgb,
hex: `#${color.rgb[0].toString(16).padStart(2, '0')}${color.rgb[1].toString(16).padStart(2, '0')}${color.rgb[2].toString(16).padStart(2, '0')}`
}));
} else {
// Fallback to CONFIG.COLOR_MAP
availableColors = Object.values(CONFIG.COLOR_MAP)
.filter(color => color.rgb !== null)
.map(color => ({
id: color.id,
name: color.name,
rgb: [color.rgb.r, color.rgb.g, color.rgb.b],
hex: `#${color.rgb.r.toString(16).padStart(2, '0')}${color.rgb.g.toString(16).padStart(2, '0')}${color.rgb.b.toString(16).padStart(2, '0')}`
}));
}
// Fallback to basic colors if nothing available
if (availableColors.length === 0) {
availableColors = [
{ id: 0, name: 'Black', rgb: [0, 0, 0], hex: '#000000' },
{ id: 1, name: 'White', rgb: [255, 255, 255], hex: '#ffffff' },
{ id: 2, name: 'Red', rgb: [255, 0, 0], hex: '#ff0000' },
{ id: 3, name: 'Green', rgb: [0, 255, 0], hex: '#00ff00' },
{ id: 4, name: 'Blue', rgb: [0, 0, 255], hex: '#0000ff' }
];
}
// Update color count
const colorCount = document.getElementById('editColorCount');
if (colorCount) {
colorCount.textContent = availableColors.length;
}
availableColors.forEach(color => {
const colorBtn = document.createElement('button');
colorBtn.className = 'color-btn';
colorBtn.style.backgroundColor = color.hex;
colorBtn.title = `${color.name} (${color.hex})`;
colorBtn.dataset.colorId = color.id;
colorBtn.dataset.colorHex = color.hex;
colorBtn.onclick = () => {
editState.currentColor = color.hex;
editState.currentColorId = color.id;
currentColorDisplay.style.backgroundColor = color.hex;
// Update active color
document.querySelectorAll('.color-btn').forEach(btn => {
btn.classList.remove('selected');
});
colorBtn.classList.add('selected');
updateStatusBar(editState.mouseX, editState.mouseY);
};
colorGrid.appendChild(colorBtn);
});
// Set first color as default
if (availableColors.length > 0) {
editState.currentColor = availableColors[0].hex;
editState.currentColorId = availableColors[0].id;
currentColorDisplay.style.backgroundColor = availableColors[0].hex;
colorGrid.firstChild.classList.add('selected');
}
}
function saveEditState() {
const editCanvas = document.getElementById('editCanvas');
const imageData = editCanvas.toDataURL();
editState.undoStack.push(imageData);
// Limit undo stack size
if (editState.undoStack.length > 50) {
editState.undoStack.shift();
}
// Clear redo stack when new action is performed
editState.redoStack = [];
updateUndoRedoButtons();
}
function undoEdit() {
if (editState.undoStack.length <= 1) return;
const currentState = editState.undoStack.pop();
editState.redoStack.push(currentState);
const previousState = editState.undoStack[editState.undoStack.length - 1];
loadEditState(previousState);
updateUndoRedoButtons();
}
function redoEdit() {
if (editState.redoStack.length === 0) return;
const nextState = editState.redoStack.pop();
editState.undoStack.push(nextState);
loadEditState(nextState);
updateUndoRedoButtons();
}
function loadEditState(imageData) {
const editCanvas = document.getElementById('editCanvas');
const ctx = editCanvas.getContext('2d');
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
// REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
ctx.drawImage(img, 0, 0);
};
img.src = imageData;
}
function updateUndoRedoButtons() {
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
if (undoBtn) {
undoBtn.disabled = editState.undoStack.length <= 1;
}
if (redoBtn) {
redoBtn.disabled = editState.redoStack.length === 0;
}
}
function updateCanvasTransform() {
const wrapper = document.getElementById('editCanvasWrapper');
if (wrapper) {
wrapper.style.transform = `translate(${editState.panX}px, ${editState.panY}px) scale(${editState.zoom})`;
}
// Update zoom select
const zoomSelect = document.getElementById('zoomSelect');
if (zoomSelect) {
// If exact value exists, use it, otherwise show nearest but update displayed text
const exact = ZOOM_LEVELS.find(z => z === editState.zoom);
zoomSelect.value = exact != null ? exact : ZOOM_LEVELS.reduce((prev, curr) => Math.abs(curr - editState.zoom) < Math.abs(prev - editState.zoom) ? curr : prev);
// Also update status bar zoom text
updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
}
}
function setupCanvasContainer() {
const canvasContainer = document.getElementById('editCanvasContainer');
const editCanvas = document.getElementById('editCanvas');
const wrapper = document.getElementById('editCanvasWrapper');
if (!canvasContainer || !wrapper) return;
// Setup container styles
canvasContainer.style.cssText = `
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background:
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
background-color: #ddd;
`;
// Setup wrapper styles
wrapper.style.cssText = `
position: relative;
transform-origin: center center;
display: inline-block;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
border: 2px solid #333;
background: white;
`;
}
function zoomToPoint(newZoom, clientX, clientY) {
const container = document.getElementById('editCanvasContainer');
const wrapper = document.getElementById('editCanvasWrapper');
if (!container || !wrapper) return;
newZoom = Math.max(0.1, Math.min(32, newZoom));
if (clientX !== undefined && clientY !== undefined) {
// Get current transform values
const oldZoom = editState.zoom;
const rect = container.getBoundingClientRect();
// Calculate zoom center point relative to container
const containerCenterX = rect.left + rect.width / 2;
const containerCenterY = rect.top + rect.height / 2;
// Calculate offset to keep zoom point centered
const offsetX = (clientX - containerCenterX) * (1 - newZoom / oldZoom);
const offsetY = (clientY - containerCenterY) * (1 - newZoom / oldZoom);
editState.panX += offsetX;
editState.panY += offsetY;
}
editState.zoom = newZoom;
constrainPan();
updateCanvasTransform();
updateMinimap();
updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
}
function constrainPan() {
const container = document.getElementById('editCanvasContainer');
const canvas = document.getElementById('editCanvas');
if (!container || !canvas) return;
const containerRect = container.getBoundingClientRect();
const scaledWidth = canvas.width * editState.zoom;
const scaledHeight = canvas.height * editState.zoom;
// Small padding around edges to avoid snapping against borders
const padding = 10;
// Calculate limits to keep canvas somewhat visible
const maxPanX = Math.max(0, (scaledWidth - containerRect.width) / 2 + padding);
const maxPanY = Math.max(0, (scaledHeight - containerRect.height) / 2 + padding);
editState.panX = Math.max(-maxPanX, Math.min(maxPanX, editState.panX));
editState.panY = Math.max(-maxPanY, Math.min(maxPanY, editState.panY));
}
function setZoom(zoom) {
zoomToPoint(zoom);
}
function zoomIn() {
const currentIndex = ZOOM_LEVELS.findIndex(z => z >= editState.zoom);
const nextIndex = Math.min(currentIndex + 1, ZOOM_LEVELS.length - 1);
setZoom(ZOOM_LEVELS[nextIndex]);
}
function zoomOut() {
const currentIndex = ZOOM_LEVELS.findIndex(z => z >= editState.zoom);
const prevIndex = Math.max(currentIndex - 1, 0);
setZoom(ZOOM_LEVELS[prevIndex]);
}
function fitToWindow() {
const container = document.getElementById('editCanvasContainer');
const canvas = document.getElementById('editCanvas');
if (!container || !canvas) return;
const containerRect = container.getBoundingClientRect();
const padding = 40;
const scaleX = (containerRect.width - padding) / canvas.width;
const scaleY = (containerRect.height - padding) / canvas.height;
const fitZoom = Math.max(0.1, Math.min(scaleX, scaleY));
// Center the canvas
editState.zoom = fitZoom;
editState.panX = 0;
editState.panY = 0;
updateCanvasTransform();
updateMinimap();
updateStatusBar(editState.mouseX || 0, editState.mouseY || 0);
}
function centerCanvas() {
editState.panX = 0;
editState.panY = 0;
updateCanvasTransform();
updateMinimap();
}
function resetEditView() {
editState.zoom = 1;
editState.panX = 0;
editState.panY = 0;
updateCanvasTransform();
updateMinimap();
}
function setupMinimap() {
const minimapCanvas = document.getElementById('minimapCanvas');
const editCanvas = document.getElementById('editCanvas');
const minimapContainer = document.getElementById('minimapContainer');
if (!minimapCanvas || !editCanvas) return;
// Show minimap only for larger images
if (editCanvas.width < 100 || editCanvas.height < 100) {
if (minimapContainer) minimapContainer.style.display = 'none';
return;
}
// Calculate minimap size
const maxSize = 150;
const scale = Math.min(maxSize / editCanvas.width, maxSize / editCanvas.height);
minimapCanvas.width = editCanvas.width * scale;
minimapCanvas.height = editCanvas.height * scale;
// Draw thumbnail
const minimapCtx = minimapCanvas.getContext('2d');
minimapCtx.imageSmoothingEnabled = false;
// REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
minimapCtx.drawImage(editCanvas, 0, 0, minimapCanvas.width, minimapCanvas.height);
// Update viewport indicator
updateMinimap();
}
function updateMinimap() {
const viewport = document.getElementById('minimapViewport');
const minimapCanvas = document.getElementById('minimapCanvas');
const container = document.getElementById('editCanvasContainer');
const editCanvas = document.getElementById('editCanvas');
if (!viewport || !minimapCanvas || !container || !editCanvas) return;
const containerRect = container.getBoundingClientRect();
const scale = minimapCanvas.width / editCanvas.width;
// Calculate visible area in minimap coordinates
const visibleWidth = Math.min(containerRect.width / editState.zoom * scale, minimapCanvas.width);
const visibleHeight = Math.min(containerRect.height / editState.zoom * scale, minimapCanvas.height);
const viewportX = (minimapCanvas.width / 2) - (editState.panX / editState.zoom * scale) - (visibleWidth / 2);
const viewportY = (minimapCanvas.height / 2) - (editState.panY / editState.zoom * scale) - (visibleHeight / 2);
viewport.style.width = `${visibleWidth}px`;
viewport.style.height = `${visibleHeight}px`;
viewport.style.left = `${Math.max(0, Math.min(minimapCanvas.width - visibleWidth, viewportX))}px`;
viewport.style.top = `${Math.max(0, Math.min(minimapCanvas.height - visibleHeight, viewportY))}px`;
}
function navigateToMinimapPosition(e) {
const minimapCanvas = document.getElementById('minimapCanvas');
const editCanvas = document.getElementById('editCanvas');
if (!minimapCanvas || !editCanvas) return;
const rect = minimapCanvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / minimapCanvas.width;
const y = (e.clientY - rect.top) / minimapCanvas.height;
// Convert to canvas coordinates and center
const targetX = (x - 0.5) * editCanvas.width * editState.zoom;
const targetY = (y - 0.5) * editCanvas.height * editState.zoom;
editState.panX = -targetX;
editState.panY = -targetY;
constrainPan();
updateCanvasTransform();
updateMinimap();
}
function updateStatusBar(x, y) {
const statusBar = document.getElementById('editStatusBar');
if (statusBar) {
const zoomPercent = Math.round(editState.zoom * 100);
statusBar.textContent = `Position: (${x}, ${y}) | Color: ${editState.currentColor || '#000000'} | Zoom: ${zoomPercent}%`;
}
}
function handleEyedropper(x, y) {
const editCanvas = document.getElementById('editCanvas');
const ctx = editCanvas.getContext('2d');
if (x >= 0 && x < editCanvas.width && y >= 0 && y < editCanvas.height) {
const imageData = ctx.getImageData(x, y, 1, 1);
const [r, g, b] = imageData.data;
const pickedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
editState.currentColor = pickedColor;
const currentColorDisplay = document.getElementById('currentColorDisplay');
if (currentColorDisplay) {
currentColorDisplay.style.backgroundColor = pickedColor;
}
// Try to find matching color in palette
document.querySelectorAll('.edit-color-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.colorHex === pickedColor) {
btn.classList.add('active');
editState.currentColorId = parseInt(btn.dataset.colorId);
}
});
updateStatusBar(x, y);
}
}
function floodFill(startX, startY, fillColor) {
const editCanvas = document.getElementById('editCanvas');
const ctx = editCanvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, editCanvas.width, editCanvas.height);
const data = imageData.data;
if (startX < 0 || startX >= editCanvas.width || startY < 0 || startY >= editCanvas.height) return;
const startIndex = (startY * editCanvas.width + startX) * 4;
const startR = data[startIndex];
const startG = data[startIndex + 1];
const startB = data[startIndex + 2];
const startA = data[startIndex + 3];
// Convert fill color to RGB
const fillR = parseInt(fillColor.slice(1, 3), 16);
const fillG = parseInt(fillColor.slice(3, 5), 16);
const fillB = parseInt(fillColor.slice(5, 7), 16);
// Don't fill if the color is already the same
if (startR === fillR && startG === fillG && startB === fillB) return;
const pixelsToCheck = [{ x: startX, y: startY }];
const checkedPixels = new Set();
while (pixelsToCheck.length > 0) {
const { x, y } = pixelsToCheck.pop();
const key = `${x},${y}`;
if (checkedPixels.has(key)) continue;
checkedPixels.add(key);
if (x < 0 || x >= editCanvas.width || y < 0 || y >= editCanvas.height) continue;
const index = (y * editCanvas.width + x) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
if (r === startR && g === startG && b === startB && a === startA) {
data[index] = fillR;
data[index + 1] = fillG;
data[index + 2] = fillB;
data[index + 3] = 255;
pixelsToCheck.push({ x: x + 1, y });
pixelsToCheck.push({ x: x - 1, y });
pixelsToCheck.push({ x, y: y + 1 });
pixelsToCheck.push({ x, y: y - 1 });
}
}
ctx.putImageData(imageData, 0, 0);
}
// Brush preview removed per user request
function hideBrushPreview() {
// No-op - brush preview disabled
}
function drawGrid(ctx, width, height) {
if (!editState.showGrid || editState.zoom < 4) return;
ctx.save();
ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)';
ctx.lineWidth = 1 / editState.zoom;
for (let x = 0; x <= width; x++) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y <= height; y++) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function drawCheckerboardBackground(ctx, width, height) {
const checkerSize = 8; // Size of each checker square
ctx.fillStyle = '#f0f0f0'; // Light gray
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#e0e0e0'; // Slightly darker gray
for (let x = 0; x < width; x += checkerSize) {
for (let y = 0; y < height; y += checkerSize) {
if ((Math.floor(x / checkerSize) + Math.floor(y / checkerSize)) % 2 === 1) {
ctx.fillRect(x, y, checkerSize, checkerSize);
}
}
}
}
function redrawCanvas() {
if (editState.undoStack.length === 0) return;
const currentState = editState.undoStack[editState.undoStack.length - 1];
const editCanvas = document.getElementById('editCanvas');
const ctx = editCanvas.getContext('2d');
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, editCanvas.width, editCanvas.height);
// REMOVED: drawCheckerboardBackground - prevents checkerboard from being saved with template
ctx.drawImage(img, 0, 0);
drawGrid(ctx, editCanvas.width, editCanvas.height);
};
img.src = currentState;
}
function setupTouchSupport() {
const canvas = document.getElementById('editCanvas');
if (!canvas) return;
let touchStartTime = 0;
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
function handleTouchStart(e) {
e.preventDefault();
touchStartTime = Date.now();
if (e.touches.length === 1) {
// Single touch - start painting
const touch = e.touches[0];
const coords = mapClientToCanvas(touch.clientX, touch.clientY);
if (coords) {
editState.isDrawing = true;
editState.lastPaintPos = { x: coords.x, y: coords.y };
paintAtPosition(coords.x, coords.y, true);
}
} else if (e.touches.length === 2) {
// Two finger - prepare for zoom/pan
const touch1 = e.touches[0];
const touch2 = e.touches[1];
editState.lastTouchDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
editState.lastTouchCenter = {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
};
}
}
function handleTouchMove(e) {
e.preventDefault();
if (e.touches.length === 1 && editState.isDrawing) {
// Continue painting
const touch = e.touches[0];
const coords = mapClientToCanvas(touch.clientX, touch.clientY);
if (coords) {
paintAtPosition(coords.x, coords.y, true);
}
} else if (e.touches.length === 2) {
// Pinch zoom and pan
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const currentDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
const currentCenter = {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
};
// Zoom based on distance change
if (editState.lastTouchDistance > 0) {
const zoomFactor = currentDistance / editState.lastTouchDistance;
const newZoom = Math.max(0.1, Math.min(32, editState.zoom * zoomFactor));
zoomToPoint(newZoom, currentCenter.x, currentCenter.y);
}
// Pan based on center movement
editState.panX += currentCenter.x - editState.lastTouchCenter.x;
editState.panY += currentCenter.y - editState.lastTouchCenter.y;
editState.lastTouchDistance = currentDistance;
editState.lastTouchCenter = currentCenter;
constrainPan();
updateCanvasTransform();
updateMinimap();
}
}
function handleTouchEnd(e) {
e.preventDefault();
if (editState.isDrawing) {
editState.isDrawing = false;
editState.lastPaintPos = null;
saveEditState();
}
if (e.touches.length === 0) {
editState.lastTouchDistance = 0;
}
}
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Only handle shortcuts when edit panel is visible
const editOverlay = document.getElementById('editOverlay');
if (!editOverlay || editOverlay.style.display === 'none') return;
// Prevent default for handled keys
const handledKeys = ['b', 'e', 'i', 'f', 'g', 'z', '[', ']'];
if (handledKeys.includes(e.key.toLowerCase()) || (e.ctrlKey && e.key.toLowerCase() === 'z')) {
e.preventDefault();
}
switch (e.key.toLowerCase()) {
case 'b': // Brush
selectTool('paint');
break;
case 'e': // Erase
selectTool('erase');
break;
case 'i': // Eyedropper
selectTool('eyedropper');
break;
case 'f': // Fill
selectTool('fill');
break;
case 'g': // Grid
const showGrid = document.getElementById('showGrid');
if (showGrid) showGrid.click();
break;
case 'z':
if (e.ctrlKey || e.metaKey) {
if (e.shiftKey) {
redoEdit();
} else {
undoEdit();
}
}
break;
case '=':
case '+':
if (e.ctrlKey || e.metaKey) {
zoomIn();
}
break;
case '-':
if (e.ctrlKey || e.metaKey) {
zoomOut();
}
break;
case '0':
if (e.ctrlKey || e.metaKey) {
fitToWindow();
}
break;
case '1':
if (e.ctrlKey || e.metaKey) {
setZoom(1);
}
break;
case '[': // Decrease brush size
const brushSize = document.getElementById('brushSize');
const currentSize = parseInt(brushSize.value);
if (currentSize > 1) {
brushSize.value = currentSize - 1;
document.getElementById('brushSizeValue').textContent = currentSize - 1;
updateBrushSize(currentSize - 1);
}
break;
case ']': // Increase brush size
const brushSize2 = document.getElementById('brushSize');
const currentSize2 = parseInt(brushSize2.value);
if (currentSize2 < 20) {
brushSize2.value = currentSize2 + 1;
document.getElementById('brushSizeValue').textContent = currentSize2 + 1;
updateBrushSize(currentSize2 + 1);
}
break;
}
});
}
function applyEditChanges() {
try {
const editCanvas = document.getElementById('editCanvas');
if (!editCanvas) {
throw new Error('Edit canvas not found');
}
// Find the resize canvas in the resize panel
const resizeCanvas = document.getElementById('resizeCanvas');
if (!resizeCanvas) {
throw new Error('Resize canvas not found');
}
const baseCtx = resizeCanvas.getContext('2d');
if (!baseCtx) {
throw new Error('Resize canvas context not available');
}
// Make sure the resize canvas has the same dimensions as the edit canvas
if (resizeCanvas.width !== editCanvas.width || resizeCanvas.height !== editCanvas.height) {
resizeCanvas.width = editCanvas.width;
resizeCanvas.height = editCanvas.height;
}
// Clear resize canvas
baseCtx.clearRect(0, 0, resizeCanvas.width, resizeCanvas.height);
// Copy edited image to resize canvas (no checkerboard since we removed it entirely)
baseCtx.imageSmoothingEnabled = false;
baseCtx.drawImage(editCanvas, 0, 0);
// CRITICAL: Completely replace the template system with edited artwork
const editedImageData = editCanvas.toDataURL();
// Create new processor with edited image as the template
if (window.WPlaceImageProcessor) {
const newProcessor = new window.WPlaceImageProcessor(editedImageData);
newProcessor.load().then(() => {
// Get the processed pixel data from the edited canvas
const editCtx = editCanvas.getContext('2d');
const editImageData = editCtx.getImageData(0, 0, editCanvas.width, editCanvas.height);
const pixels = editImageData.data;
// Count valid pixels in the edited image
let totalValidPixels = 0;
for (let i = 0; i < pixels.length; i += 4) {
const a = pixels[i + 3];
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const isTransparent = !state.paintTransparentPixels && a < state.customTransparencyThreshold;
const isWhiteAndSkipped = !state.paintWhitePixels && Utils.isWhitePixel(r, g, b);
if (!isTransparent && !isWhiteAndSkipped) {
totalValidPixels++;
}
}
// COMPLETELY REBUILD state.imageData with the edited artwork
state.imageData = {
width: editCanvas.width,
height: editCanvas.height,
pixels: pixels,
totalPixels: totalValidPixels,
processor: newProcessor,
};
// CRITICAL: Update state.originalImage so resize panel uses edited artwork as base template
state.originalImage = {
dataUrl: editedImageData,
width: editCanvas.width,
height: editCanvas.height
};
// Update state with new totals
state.totalPixels = totalValidPixels;
state.paintedPixels = 0; // Reset progress since this is a new template
state.currentPaintingColor = null; // Reset color tracking
state.imageLoaded = true;
// Update local processors
if (typeof processor !== 'undefined') {
processor = newProcessor;
console.log('🔄 Updated local processor with edited artwork');
}
if (typeof baseProcessor !== 'undefined') {
baseProcessor = newProcessor;
console.log('🔄 Updated baseProcessor with edited artwork');
}
// Force regeneration of overlays by clearing cached mask data
if (typeof window._maskImageData !== 'undefined') {
delete window._maskImageData;
}
// Update UI to reflect the new template
if (typeof updateUI === 'function') {
updateUI();
}
// Show loading and properly reload resize panel with new template
Utils.showAlert('Updating template... Please wait.', 'info');
// Give more time for template to fully update and force resize panel reload
setTimeout(() => {
// Hide edit overlay first
document.getElementById('editOverlay').style.display = 'none';
// Completely reload resize dialog with updated processor
setTimeout(() => {
// Clean up existing dialog
if (typeof _resizeDialogCleanup === 'function') {
_resizeDialogCleanup();
}
// Force complete reload of resize dialog with new processor
setTimeout(() => {
showResizeDialog(newProcessor);
console.log('✅ Template COMPLETELY replaced with edited artwork');
console.log(`📊 New template stats: ${editCanvas.width}x${editCanvas.height}, ${totalValidPixels} pixels`);
Utils.showAlert('Template successfully replaced with your edited artwork!', 'success');
}, 100);
}, 300);
}, 100);
}).catch((error) => {
console.error('Failed to load new processor:', error);
Utils.showAlert('Failed to update template. Please try again.', 'error');
});
} else {
console.error('WPlaceImageProcessor not available');
Utils.showAlert('Image processor not available. Please reload the page.', 'error');
}
console.log('✅ Edit changes applied - template replacement in progress');
} catch (error) {
console.error('Error applying edit changes:', error);
Utils.showAlert('Failed to apply changes. Please try again.', 'error');
}
}
if (uploadBtn) {
uploadBtn.addEventListener('click', async () => {
// Auto-open color palette if not already open
Utils.openColorPalette();
const availableColors = Utils.extractAvailableColors();
if (availableColors === null || availableColors.length < 10) {
updateUI('noColorsFound', 'error');
Utils.showAlert(Utils.t('noColorsFound'), 'error');
return;
}
if (!state.colorsChecked) {
state.availableColors = availableColors;
state.colorsChecked = true;
updateUI('colorsFound', 'success', { count: availableColors.length });
await updateStats();
selectPosBtn.disabled = false;
// Only enable resize button if image is also loaded
if (state.imageLoaded) {
resizeBtn.disabled = false;
}
}
try {
updateUI('loadingImage', 'default');
const imageSrc = await Utils.createImageUploader();
if (!imageSrc) {
updateUI('colorsFound', 'success', {
count: state.availableColors.length,
});
return;
}
const processor = new ImageProcessor(imageSrc);
await processor.load();
const { width, height } = processor.getDimensions();
const pixels = processor.getPixelData();
let totalValidPixels = 0;
for (let i = 0; i < pixels.length; i += 4) {
const isTransparent =
!state.paintTransparentPixels &&
pixels[i + 3] < (state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD);
const isWhiteAndSkipped =
!state.paintWhitePixels &&
Utils.isWhitePixel(pixels[i], pixels[i + 1], pixels[i + 2]);
if (!isTransparent && !isWhiteAndSkipped) {
totalValidPixels++;
}
}
state.imageData = {
width,
height,
pixels,
totalPixels: totalValidPixels,
processor,
};
state.totalPixels = totalValidPixels;
state.paintedPixels = 0;
state.currentPaintingColor = null; // Reset color tracking
state.imageLoaded = true;
state.imageProcessed = false; // New upload - not processed yet
// Reset session-specific flags when a new image is loaded
state.preFilteringDone = false;
state.progressResetDone = false;
console.log('🔄 Reset session flags for new image load');
// Keep existing lastPosition to continue from where we left off
// state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
// Initialize painted map for tracking
Utils.initializePaintedMap(width, height);
// New image: clear previous resize settings
state.resizeSettings = null;
// Also clear any previous ignore mask
state.resizeIgnoreMask = null;
// Save original image for this browser (dataUrl + dims)
state.originalImage = { dataUrl: imageSrc, width, height };
saveBotSettings();
// ⚠️ Use the ORIGINAL image for the overlay initially (will be replaced after processing)
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;
// ⚠️ IMPORTANT: Disable position selection until image is processed
selectPosBtn.disabled = true;
selectPosBtn.title = Utils.t('imageNeedsProcessing');
// Disable start button since position can't be set yet
if (state.startPosition) {
startBtn.disabled = true; // Disable even if position was set before
}
await updateStats();
updateDataButtons();
// Show warning alert to process image first
Utils.showAlert(Utils.t('processImageFirst'), 'warning');
updateUI('imageLoaded', 'success', { count: totalValidPixels });
} catch {
updateUI('imageError', 'error');
}
});
}
// Load Extracted button event listener - for loading Art-Extractor JSON files
const loadExtractedBtn = document.getElementById('loadExtractedBtn');
if (loadExtractedBtn) {
loadExtractedBtn.addEventListener('click', async () => {
try {
// Auto-open color palette if not already open
Utils.openColorPalette();
updateUI('loadingImage', 'default');
const fileData = await Utils.loadExtractedFileData();
if (!fileData) {
updateUI('ready', 'default');
return;
}
console.log('📁 Loading extracted artwork from Art-Extractor...');
console.log('🔍 [DEBUG] Loaded file data structure:', {
hasState: !!fileData.state,
hasImageData: !!fileData.imageData,
topLevelKeys: Object.keys(fileData),
stateKeys: fileData.state ? Object.keys(fileData.state) : 'N/A',
imageDataKeys: fileData.imageData ? Object.keys(fileData.imageData) : 'N/A',
fileDataType: typeof fileData,
stateType: typeof fileData.state,
imageDataType: typeof fileData.imageData
});
// Validate the data structure before restoring
if (!fileData || typeof fileData !== 'object') {
throw new Error('Invalid file format: File data is not a valid object');
}
if (!fileData.state || typeof fileData.state !== 'object') {
console.error('❌ State validation failed. FileData:', fileData);
throw new Error('Invalid file format: Missing or invalid state object. Please ensure you exported from Art-Extractor correctly.');
}
if (!fileData.imageData || typeof fileData.imageData !== 'object') {
console.error('❌ ImageData validation failed. FileData:', fileData);
throw new Error('Invalid file format: Missing or invalid imageData object. Please ensure you completed the area scan in Art-Extractor.');
}
if (!fileData.imageData.pixels || !Array.isArray(fileData.imageData.pixels) || fileData.imageData.pixels.length === 0) {
console.error('❌ Pixel data validation failed. ImageData:', fileData.imageData);
throw new Error('Invalid file format: No pixel data found. Please scan an area in Art-Extractor before exporting.');
}
// Ensure critical fields are present for Art-Extractor compatibility
if (!fileData.state.availableColors) {
console.warn('⚠️ No availableColors in file, using defaults');
fileData.state.availableColors = [];
}
// Reset session-specific flags when loading extracted file data
state.preFilteringDone = false;
state.progressResetDone = false;
console.log('🔄 Reset session flags for extracted file load');
// Use the existing restoreProgress function but with special handling
const restoreSuccess = await Utils.restoreProgress(fileData);
if (!restoreSuccess) {
throw new Error('Failed to restore progress data');
}
// After loading, we need to enter position selection mode like when uploading images
if (state.imageLoaded) {
console.log('🎯 Extracted artwork loaded, entering position selection mode...');
// Clear position data since Art-Extractor exports may have null positions for manual placement
state.startPosition = null;
state.region = null;
state.selectingPosition = true;
// For extracted files, force enable all necessary flags for full functionality
state.colorsChecked = true;
state.currentPaintingColor = null; // Reset color tracking
state.imageLoaded = true;
// Ensure image processor is available for extracted files
if (state.imageData && !state.imageData.processor) {
try {
// Create a temporary canvas to generate a data URL for the processor
const canvas = document.createElement('canvas');
canvas.width = state.imageData.width;
canvas.height = state.imageData.height;
const ctx = canvas.getContext('2d');
// Create image data from pixels
const imageData = new ImageData(
new Uint8ClampedArray(state.imageData.pixels),
state.imageData.width,
state.imageData.height
);
ctx.putImageData(imageData, 0, 0);
// Create processor with canvas data URL using the correct class
const dataUrl = canvas.toDataURL();
state.imageData.processor = new window.WPlaceImageProcessor(dataUrl);
// CRITICAL: Load the processor before using it
await state.imageData.processor.load();
console.log('🔧 Created and loaded WPlaceImageProcessor for extracted artwork');
} catch (error) {
console.error('❌ Failed to create image processor for extracted artwork:', error);
// Fallback: use global processor if available
if (window.globalImageProcessor) {
console.log('🔄 Using global image processor as fallback');
state.imageData.processor = window.globalImageProcessor;
}
}
}
console.log('🎨 Force-enabled all flags for extracted JSON to access resize panel');
// For extracted images, ensure overlay is enabled but don't set position yet
try {
if (overlayManager && state.imageData) {
console.log('🖼️ Creating overlay from loaded pixel data:', {
width: state.imageData.width,
height: state.imageData.height,
pixelCount: state.imageData.pixels?.length,
firstPixels: state.imageData.pixels?.slice(0, 12), // First 3 pixels (RGBA)
isUint8ClampedArray: state.imageData.pixels instanceof Uint8ClampedArray,
isArray: Array.isArray(state.imageData.pixels)
});
// Create image bitmap from the restored image data
const canvas = new OffscreenCanvas(state.imageData.width, state.imageData.height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(
new Uint8ClampedArray(state.imageData.pixels),
state.imageData.width,
state.imageData.height
);
ctx.putImageData(imageData, 0, 0);
const imageBitmap = await canvas.transferToImageBitmap();
await overlayManager.setImage(imageBitmap);
overlayManager.enable();
console.log('✅ Overlay enabled for extracted artwork - should show PROCESSED pixels');
// ✅ Loaded files already contain processed pixels - no need to reprocess
// Mark as processed since the overlay is showing the correct processed data
state.imageProcessed = true;
// Enable position selection immediately
selectPosBtn.disabled = false;
selectPosBtn.title = Utils.t('selectPosition') || 'Select starting position on canvas';
}
} catch (overlayError) {
console.warn('⚠️ Could not set overlay for extracted artwork:', overlayError);
}
// For extracted images, don't try to set overlay position until user selects one
// The overlay should already be enabled from the restoreProgress function
// Update UI with custom message for extracted artwork
Utils.showAlert('🎨 Extracted artwork loaded! Please click on the canvas to select where to place it.', 'info');
updateUI('ready', 'default'); // Use 'ready' instead of 'waitingPosition' to avoid translation issues
// Enable relevant buttons
selectPosBtn.disabled = false;
// Always enable resize button for extracted files since they have image processor
resizeBtn.disabled = false;
console.log('🔧 Resize button enabled for extracted artwork');
// Enable move artwork button (will be fully enabled after position is set)
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = false;
}
// Set up position selection like the regular selectPosBtn
const tempFetch = async (url, options) => {
if (
typeof url === 'string' &&
url.includes('/s0/pixel/') &&
options &&
options.method === 'POST'
) {
let coords;
try {
const body = JSON.parse(options.body);
coords = body.coords;
} catch (e) {
return window.originalFetch(url, options);
}
const tileMatches = url.match(/\/s0\/pixel\/(\-?\d+)\/(\-?\d+)/);
if (tileMatches && coords && coords.length >= 2) {
const tileX = parseInt(tileMatches[1]);
const tileY = parseInt(tileMatches[2]);
const pixelX = coords[0];
const pixelY = coords[1];
state.startPosition = { x: pixelX, y: pixelY };
state.region = { x: tileX, y: tileY };
state.selectingPosition = false;
console.log('🎯 Position selected for extracted artwork:', {
startPosition: state.startPosition,
region: state.region
});
// Restore original fetch
window.fetch = window.originalFetch;
// Update overlay position with validation
try {
if (state.startPosition && state.region && overlayManager) {
await overlayManager.setPosition(state.startPosition, state.region);
console.log('✅ Overlay position updated successfully');
} else {
console.warn('⚠️ Cannot set overlay position: missing startPosition or region');
}
} catch (positionError) {
console.error('❌ Failed to set overlay position:', positionError);
}
startBtn.disabled = false;
selectPosBtn.textContent = Utils.t('selectPosition');
// Enable Move Artwork button when position is set
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = false;
}
updateUI('ready', 'success');
Utils.showAlert('Position selected! Ready to start painting.', 'success');
}
}
return window.originalFetch(url, options);
};
// Store original fetch and replace with position capture
if (!window.originalFetch) {
window.originalFetch = window.fetch;
}
window.fetch = tempFetch;
}
} catch (error) {
console.error('❌ Failed to load extracted artwork:', error);
Utils.showAlert(`Failed to load extracted artwork: ${error.message}`, 'error');
updateUI('ready', 'default');
}
});
}
if (resizeBtn) {
resizeBtn.addEventListener('click', () => {
console.log('🔍 [DEBUG] Resize button clicked. State check:', {
imageLoaded: state.imageLoaded,
hasProcessor: !!(state.imageData && state.imageData.processor),
colorsChecked: state.colorsChecked,
hasAvailableColors: !!(state.availableColors && state.availableColors.length > 0)
});
if (state.imageLoaded && state.imageData && state.imageData.processor) {
showResizeDialog(state.imageData.processor);
} else {
let message = 'Please upload an image and check colors first.';
if (!state.imageLoaded) message = 'Please upload an image first.';
else if (!state.imageData || !state.imageData.processor) message = 'Image processor not available. Please reload the image.';
Utils.showAlert(message, 'warning');
}
});
}
// Move Artwork button event listener
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.addEventListener('click', () => {
if (state.imageLoaded && (state.startPosition || state.selectingPosition)) {
showMoveArtworkPanel();
} else if (!state.imageLoaded) {
Utils.showAlert('Please upload or load an image first', 'warning');
} else {
Utils.showAlert('Please select a position for the artwork first', 'warning');
}
});
}
if (selectPosBtn) {
selectPosBtn.addEventListener('click', async () => {
if (state.selectingPosition) return;
state.selectingPosition = true;
state.startPosition = null;
state.region = null;
startBtn.disabled = true;
// Disable Move Artwork button when selecting new position
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = true;
}
Utils.showAlert(Utils.t('selectPositionAlert'), 'info');
updateUI('waitingPosition', 'default');
const tempFetch = async (url, options) => {
if (
typeof url === 'string' &&
url.includes('https://backend.wplace.live/s0/pixel/') &&
options?.method?.toUpperCase() === 'POST'
) {
try {
const response = await originalFetch(url, options);
const clonedResponse = response.clone();
const data = await clonedResponse.json();
if (data?.painted === 1) {
const regionMatch = url.match(/\/pixel\/(\d+)\/(\d+)/);
if (regionMatch && regionMatch.length >= 3) {
state.region = {
x: Number.parseInt(regionMatch[1]),
y: Number.parseInt(regionMatch[2]),
};
}
const payload = JSON.parse(options.body);
if (payload?.coords && Array.isArray(payload.coords)) {
state.startPosition = {
x: payload.coords[0],
y: payload.coords[1],
};
// Keep existing lastPosition to continue from where we left off
// state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
// Update overlay position with validation
try {
if (state.startPosition && state.region && overlayManager) {
await overlayManager.setPosition(state.startPosition, state.region);
console.log('✅ Regular overlay position updated successfully');
} else {
console.warn('⚠️ Cannot set regular overlay position: missing startPosition or region');
}
} catch (positionError) {
console.error('❌ Failed to set regular overlay position:', positionError);
}
if (state.imageLoaded) {
startBtn.disabled = false;
// Enable Move Artwork button when position is set
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = false;
}
}
window.fetch = originalFetch;
state.selectingPosition = false;
updateUI('positionSet', 'success');
}
}
return response;
} catch {
return originalFetch(url, options);
}
}
return originalFetch(url, options);
};
const originalFetch = window.fetch;
window.fetch = tempFetch;
setTimeout(() => {
if (state.selectingPosition) {
window.fetch = originalFetch;
state.selectingPosition = false;
updateUI('positionTimeout', 'error');
Utils.showAlert(Utils.t('positionTimeout'), 'error');
}
}, 120000);
});
}
async function startPainting() {
if (!state.imageLoaded || !state.startPosition || !state.region) {
updateUI('missingRequirements', 'error');
return;
}
await ensureToken();
if (!getTurnstileToken()) return;
// Only perform progressive pixel detection on first start of session
if (!state.preFilteringDone) {
// Perform progressive pixel detection from top-left to bottom-right
console.log('🔍 Starting progressive pixel detection from top-left to bottom-right...');
await performProgressivePixelDetection();
// Mark pre-filtering as done to prevent duplicate scanning
state.preFilteringDone = true;
console.log('✅ Pre-filtering marked as complete - will not scan again this session');
} else {
console.log('🔄 Continuing session - pre-filtering already done');
}
state.running = true;
state.stopFlag = false;
startBtn.disabled = true;
stopBtn.disabled = false;
uploadBtn.disabled = true;
selectPosBtn.disabled = true;
resizeBtn.disabled = true;
saveBtn.disabled = true;
toggleOverlayBtn.disabled = true;
updateUI('startPaintingMsg', 'success');
try {
await getAccounts();
await processImage();
} catch (e) {
console.error('Unexpected error:', e);
updateUI('paintingError', 'error');
} finally {
state.running = false;
stopBtn.disabled = true;
saveBtn.disabled = false;
if (state.stopFlag) {
startBtn.disabled = false;
} else {
startBtn.disabled = true;
uploadBtn.disabled = false;
const loadExtractedBtn = document.getElementById('loadExtractedBtn');
if (loadExtractedBtn) loadExtractedBtn.disabled = false;
selectPosBtn.disabled = false;
resizeBtn.disabled = false;
}
toggleOverlayBtn.disabled = false;
}
}
// Helper function to update start button state
function updateStartButtonState() {
if (!startBtn) return;
if (state.paintingMode === 'assist') {
startBtn.disabled = true;
startBtn.title = 'Start button is disabled in Assist mode. Manually place pixels with overlay guidance.';
} else {
startBtn.disabled = !state.imageLoaded || state.running || !state.startPosition;
startBtn.title = '';
}
}
if (startBtn) {
startBtn.addEventListener('click', startPainting);
}
// Painting Mode Toggle
const paintingModeToggle = container.querySelector('#paintingModeToggle');
if (paintingModeToggle) {
// Initialize toggle state - MUST be unchecked for auto mode (default)
paintingModeToggle.checked = state.paintingMode === 'assist';
console.log(`🎨 [Painting Mode] Initial state: ${state.paintingMode}, Toggle checked: ${paintingModeToggle.checked}`);
paintingModeToggle.addEventListener('change', () => {
state.paintingMode = paintingModeToggle.checked ? 'assist' : 'auto';
// Update start button state
updateStartButtonState();
// NOTE: We don't save paintingMode - it always resets to 'auto' on page load
// Show notification
const modeName = state.paintingMode === 'assist' ? 'Assist' : 'Auto';
const modeDesc = state.paintingMode === 'assist'
? 'Overlay will guide your manual pixel placement'
: 'Bot will automatically paint pixels';
Utils.showAlert(`Painting Mode: ${modeName}\n${modeDesc}`, 'info');
console.log(`🎨 [Painting Mode] Switched to ${modeName.toUpperCase()} mode`);
});
// Initial state update
updateStartButtonState();
}
if (stopBtn) {
stopBtn.addEventListener('click', () => {
state.stopFlag = true;
state.running = false;
stopBtn.disabled = true;
updateUI('paintingStoppedByUser', 'warning');
if (state.imageLoaded && state.paintedPixels > 0) {
Utils.saveProgress();
Utils.showAlert(Utils.t('autoSaved'), 'success');
}
});
}
const checkSavedProgress = () => {
const savedData = Utils.loadProgress();
if (savedData && savedData.state.paintedPixels > 0) {
const savedDate = new Date(savedData.timestamp).toLocaleString();
const progress = Math.round(
(savedData.state.paintedPixels / savedData.state.totalPixels) * 100
);
Utils.showAlert(
`${Utils.t('savedDataFound')}\n\n` +
`Saved: ${savedDate}\n` +
`Progress: ${savedData.state.paintedPixels}/${savedData.state.totalPixels} pixels (${progress}%)\n` +
`${Utils.t('clickLoadToContinue')}`,
'info'
);
}
};
setTimeout(checkSavedProgress, 1000);
if (cooldownSlider && cooldownInput && cooldownValue && cooldownDecrease && cooldownIncrease) {
const updateCooldown = (newValue) => {
const threshold = Math.max(1, Math.min(state.maxCharges || 999, parseInt(newValue)));
state.cooldownChargeThreshold = threshold;
// Update both controls (value shows in input, label shows unit only)
cooldownSlider.value = threshold;
cooldownInput.value = threshold;
cooldownValue.textContent = `${Utils.t('charges')}`;
saveBotSettings();
NotificationManager.resetEdgeTracking(); // prevent spurious notify after threshold change
};
// Slider event listener
cooldownSlider.addEventListener('input', (e) => {
updateCooldown(e.target.value);
});
// Number input event listener
cooldownInput.addEventListener('input', (e) => {
updateCooldown(e.target.value);
});
// Decrease button
cooldownDecrease.addEventListener('click', () => {
updateCooldown(parseInt(cooldownInput.value) - 1);
});
// Increase button
cooldownIncrease.addEventListener('click', () => {
updateCooldown(parseInt(cooldownInput.value) + 1);
});
// Add scroll-to-adjust for cooldown slider
Utils.createScrollToAdjust(cooldownSlider, updateCooldown, 1, state.maxCharges, 1);
// Skip cooldown button event handler
const skipCooldownBtn = document.getElementById('skipCooldownBtn');
if (skipCooldownBtn) {
skipCooldownBtn.addEventListener('click', () => {
if (state.preciseCurrentCharges < state.cooldownChargeThreshold) {
console.log(`[Auto-Image] Skip cooldown requested - resetting to account index 0`);
// Reset to account index 0 (start new cycle)
state.currentActiveIndex = 0;
console.log(`🔄 Reset currentActiveIndex to 0 for new cycle`);
// Reset charges to threshold to bypass cooldown
state.preciseCurrentCharges = state.cooldownChargeThreshold;
console.log(`[Auto-Image] Cooldown skipped! Charges set to threshold: ${state.cooldownChargeThreshold}`);
// Switch to first account if we have multiple accounts
if (accountManager.getAccountCount() > 1) {
const firstAccountInfo = accountManager.getAccountByIndex(0);
if (firstAccountInfo && firstAccountInfo.token) {
console.log(`🔄 Switching to first account: index 0 (${firstAccountInfo.displayName})`);
// Get accounts array for the switch
const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
if (accounts.length > 0) {
switchToSpecificAccount(firstAccountInfo.token, firstAccountInfo.displayName).then(() => {
console.log(`✅ Successfully switched to account index 0 after cooldown skip`);
// Update UI immediately
updateChargesThresholdUI(0);
// Trigger immediate check for painting
if (state.isEnabled && !state.stopFlag) {
setTimeout(() => {
checkAndPaint();
}, 100);
}
}).catch(err => {
console.error(`❌ Failed to switch to account index 0:`, err);
});
} else {
console.warn(`⚠️ No accounts available for switching`);
}
} else {
console.warn(`⚠️ First account info not found or missing token`);
}
} else {
console.log(`📝 Single account mode - no switching needed`);
// Update UI immediately
updateChargesThresholdUI(0);
// Trigger immediate check for painting
if (state.isEnabled && !state.stopFlag) {
setTimeout(() => {
checkAndPaint();
}, 100);
}
}
}
});
}
}
loadBotSettings();
// Ensure notification poller reflects current settings
NotificationManager.syncFromState();
// Sync painting mode toggle with loaded state
if (paintingModeToggle) {
paintingModeToggle.checked = state.paintingMode === 'assist';
updateStartButtonState();
console.log(`🔄 [Painting Mode] Synced after load: ${state.paintingMode}, Toggle checked: ${paintingModeToggle.checked}`);
}
}
function getMsToTargetCharges(current, target, cooldown, intervalMs = 0) {
const remainingCharges = target - current;
return Math.max(0, remainingCharges * cooldown - intervalMs);
}
function updateChargesThresholdUI(intervalMs) {
if (state.stopFlag) return;
const threshold = state.cooldownChargeThreshold;
const remainingMs = getMsToTargetCharges(
state.preciseCurrentCharges,
threshold,
state.cooldown,
intervalMs
);
const timeText = Utils.msToTimeText(remainingMs);
updateUI(
'noChargesThreshold',
'warning',
{
threshold,
current: state.displayCharges,
time: timeText,
},
true
);
// Update skip cooldown button state
const skipCooldownBtn = document.getElementById('skipCooldownBtn');
if (skipCooldownBtn) {
const isInCooldown = state.preciseCurrentCharges < threshold && remainingMs > 0;
skipCooldownBtn.disabled = !isInCooldown;
skipCooldownBtn.title = isInCooldown
? `Skip cooldown (${timeText} remaining)`
: 'Skip cooldown (only available during cooldown)';
}
}
// Fast tile-based pixel detection from top-left to bottom-right
async function performProgressivePixelDetection() {
if (!state.imageLoaded || !state.imageData) {
console.log('⚠️ No image loaded, skipping pixel detection');
return;
}
const startTime = performance.now();
const { width, height } = state.imageData;
const startX = state.startPosition.x;
const startY = state.startPosition.y;
const regionX = state.region.x;
const regionY = state.region.y;
let detectedPixels = 0;
let totalChecked = 0;
console.log(`🚀 Fast scanning ${width}x${height} image from top-left (0,0) to bottom-right (${width - 1},${height - 1})...`);
updateUI('pixelDetection', 'info', { message: 'Fast detecting already painted pixels...' });
// Calculate affected tiles
const worldX1 = startX;
const worldY1 = startY;
const worldX2 = startX + width - 1;
const worldY2 = startY + height - 1;
const startTileX = Math.floor(worldX1 / 1000);
const startTileY = Math.floor(worldY1 / 1000);
const endTileX = Math.floor(worldX2 / 1000);
const endTileY = Math.floor(worldY2 / 1000);
console.log(`📄 Processing tiles from (${startTileX},${startTileY}) to (${endTileX},${endTileY})`);
// Cache for downloaded tile data
const tileDataCache = new Map();
// Download and cache all required tiles in parallel
const tilePromises = [];
for (let tileY = startTileY; tileY <= endTileY; tileY++) {
for (let tileX = startTileX; tileX <= endTileX; tileX++) {
const absoluteTileX = regionX + tileX;
const absoluteTileY = regionY + tileY;
const tileKey = `${absoluteTileX},${absoluteTileY}`;
tilePromises.push(
downloadTileImageData(absoluteTileX, absoluteTileY).then(imageData => {
if (imageData) {
tileDataCache.set(tileKey, imageData);
}
}).catch(e => {
console.warn(`⚠️ Failed to download tile ${absoluteTileX},${absoluteTileY}:`, e.message);
})
);
}
}
// Wait for all tiles to download
await Promise.all(tilePromises);
console.log(`📦 Downloaded ${tileDataCache.size} tiles for fast pixel checking`);
// Fast pixel detection using cached tile data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
totalChecked++;
// Check if pixel is eligible for painting
const targetPixelInfo = checkPixelEligibility(x, y);
if (!targetPixelInfo.eligible) {
continue; // Skip non-eligible pixels
}
// Calculate absolute world coordinates
const absX = startX + x;
const absY = startY + y;
const adderX = Math.floor(absX / 1000);
const adderY = Math.floor(absY / 1000);
const pixelX = absX % 1000;
const pixelY = absY % 1000;
const absoluteTileX = regionX + adderX;
const absoluteTileY = regionY + adderY;
// Check if already marked as painted in local map
if (Utils.isPixelPainted(x, y, absoluteTileX, absoluteTileY)) {
detectedPixels++;
continue;
}
// Fast pixel color check using cached tile data
const tileKey = `${absoluteTileX},${absoluteTileY}`;
const tileImageData = tileDataCache.get(tileKey);
if (tileImageData) {
try {
// Direct array access - much faster than canvas operations
const tileWidth = tileImageData.width;
const tileHeight = tileImageData.height;
const data = tileImageData.data;
// Ensure pixel coordinates are within tile bounds
if (pixelX >= 0 && pixelX < tileWidth && pixelY >= 0 && pixelY < tileHeight) {
const pixelIndex = (pixelY * tileWidth + pixelX) * 4;
const r = data[pixelIndex];
const g = data[pixelIndex + 1];
const b = data[pixelIndex + 2];
const a = data[pixelIndex + 3];
// Check alpha threshold
const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
if (a >= alphaThresh) {
const existingMappedColor = Utils.resolveColor(
[r, g, b],
state.availableColors,
!state.paintUnavailablePixels
);
const isAlreadyPainted = existingMappedColor.id === targetPixelInfo.mappedColorId;
if (isAlreadyPainted) {
// Check if pixel is already marked as painted to avoid double counting
if (!Utils.isPixelPainted(x, y, absoluteTileX, absoluteTileY)) {
// Mark as painted in the map but DO NOT increment progress counter
// Progress counter should only reflect actual painting sequence position
Utils.markPixelPainted(x, y, absoluteTileX, absoluteTileY);
detectedPixels++;
} else {
// Pixel already tracked, just count it for detection stats
detectedPixels++;
}
}
}
}
} catch (e) {
// Skip pixels we can't check
console.warn(`⚠️ Could not check pixel (${x}, ${y}):`, e.message);
}
}
// Update progress periodically
if (totalChecked % 2500 === 0) {
const progress = Math.round((totalChecked / (width * height)) * 100);
updateUI('pixelDetection', 'info', {
message: `Fast detecting... ${progress}% (Found: ${detectedPixels})`
});
// Yield control briefly to prevent UI blocking
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
const processingTime = Math.round(performance.now() - startTime);
console.log(`🏁 Fast pixel detection complete in ${processingTime}ms:`);
console.log(` - Total pixels checked: ${totalChecked}`);
console.log(` - Already painted pixels found: ${detectedPixels}`);
console.log(` - Updated progress: ${state.paintedPixels}/${state.totalPixels}`);
console.log(` - Performance: ${Math.round(totalChecked / (processingTime / 1000))} pixels/second`);
// Update progress display
await updateStats();
updateUI('pixelDetectionComplete', 'success', {
message: `Found ${detectedPixels} already painted pixels in ${processingTime}ms`
});
}
// Fast tile download and ImageData extraction (similar to Art-Extractor approach)
async function downloadTileImageData(tileX, tileY) {
try {
const tileUrl = `https://backend.wplace.live/files/s0/tiles/${tileX}/${tileY}.png`;
const response = await fetch(tileUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
return await processTileBlob(blob);
} catch (error) {
console.warn(`Failed to download tile ${tileX},${tileY}:`, error.message);
return null;
}
}
// Process tile blob into ImageData (adapted from Art-Extractor)
async function processTileBlob(blob) {
try {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
return new Promise((resolve, reject) => {
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
resolve(imageData);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(blob);
});
} catch (error) {
console.error('Error processing tile blob:', error);
return null;
}
}
function generateCoordinates(width, height, mode, direction, snake, blockWidth, blockHeight, startFromX = 0, startFromY = 0) {
const coords = [];
console.log(
'Generating coordinates with \n mode:',
mode,
'\n direction:',
direction,
'\n snake:',
snake,
'\n blockWidth:',
blockWidth,
'\n blockHeight:',
blockHeight,
'\n startFromX:',
startFromX,
'\n startFromY:',
startFromY
);
// --------- Standard 4 corners traversal ----------
let xStart, xEnd, xStep;
let yStart, yEnd, yStep;
switch (direction) {
case 'top-left':
xStart = 0;
xEnd = width;
xStep = 1;
yStart = 0;
yEnd = height;
yStep = 1;
break;
case 'top-right':
xStart = width - 1;
xEnd = -1;
xStep = -1;
yStart = 0;
yEnd = height;
yStep = 1;
break;
case 'bottom-left':
xStart = 0;
xEnd = width;
xStep = 1;
yStart = height - 1;
yEnd = -1;
yStep = -1;
break;
case 'bottom-right':
xStart = width - 1;
xEnd = -1;
xStep = -1;
yStart = height - 1;
yEnd = -1;
yStep = -1;
break;
default:
throw new Error(`Unknown direction: ${direction}`);
}
// --------- Traversal modes ----------
if (mode === 'rows') {
let rowIndex = 0;
for (let y = yStart; y !== yEnd; y += yStep) {
if (snake && rowIndex % 2 !== 0) {
for (let x = xEnd - xStep; x !== xStart - xStep; x -= xStep) {
coords.push([x, y]);
}
} else {
for (let x = xStart; x !== xEnd; x += xStep) {
coords.push([x, y]);
}
}
rowIndex++;
}
} else if (mode === 'columns') {
let colIndex = 0;
for (let x = xStart; x !== xEnd; x += xStep) {
if (snake && colIndex % 2 !== 0) {
for (let y = yEnd - yStep; y !== yStart - yStep; y -= yStep) {
coords.push([x, y]);
}
} else {
for (let y = yStart; y !== yEnd; y += yStep) {
coords.push([x, y]);
}
}
colIndex++;
}
} else if (mode === 'circle-out') {
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
for (let r = 0; r <= maxRadius; r++) {
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
if (dist === r) coords.push([x, y]);
}
}
}
}
} else if (mode === 'circle-in') {
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
for (let r = maxRadius; r >= 0; r--) {
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
if (dist === r) coords.push([x, y]);
}
}
}
}
} else if (mode === 'blocks' || mode === 'shuffle-blocks') {
const blocks = [];
for (let by = 0; by < height; by += blockHeight) {
for (let bx = 0; bx < width; bx += blockWidth) {
const block = [];
for (let y = by; y < Math.min(by + blockHeight, height); y++) {
for (let x = bx; x < Math.min(bx + blockWidth, width); x++) {
block.push([x, y]);
}
}
blocks.push(block);
}
}
if (mode === 'shuffle-blocks') {
// Simple Fisher-Yates shuffle
for (let i = blocks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[blocks[i], blocks[j]] = [blocks[j], blocks[i]];
}
}
// Append all blocks without spread/concat to avoid large allocations and argument explosion
for (const block of blocks) {
for (let i = 0; i < block.length; i++) {
coords.push(block[i]);
}
}
} else {
throw new Error(`Unknown mode: ${mode}`);
}
// Filter coordinates to start from the specified position
if (startFromX > 0 || startFromY > 0) {
console.log(`🔄 Filtering coordinates to resume from position (${startFromX}, ${startFromY})`);
let startIndex = -1;
// Find the starting position in the coordinate list
for (let i = 0; i < coords.length; i++) {
const [x, y] = coords[i];
if (x === startFromX && y === startFromY) {
startIndex = i;
break;
}
}
if (startIndex >= 0) {
// Resume from the found position (skip all previous coordinates)
const filteredCoords = coords.slice(startIndex);
console.log(`✂️ Resuming: skipped ${startIndex} coordinates, continuing with ${filteredCoords.length} remaining`);
return filteredCoords;
} else {
console.warn(`⚠️ Resume position (${startFromX}, ${startFromY}) not found in coordinate list, starting from beginning`);
}
}
return coords;
}
async function flushPixelBatch(pixelBatch) {
if (!pixelBatch || pixelBatch.pixels.length === 0) return true;
const batchSize = pixelBatch.pixels.length;
console.log(
`📦 Sending batch with ${batchSize} pixels (region: ${pixelBatch.regionX},${pixelBatch.regionY})`
);
const success = await sendBatchWithRetry(
pixelBatch.pixels,
pixelBatch.regionX,
pixelBatch.regionY
);
if (success) {
// Only increment progress for actually painted pixels to prevent multiplication
const actuallyPaintedCount = pixelBatch.pixels.length;
state.paintedPixels += actuallyPaintedCount;
console.log(`📊 Added ${actuallyPaintedCount} painted pixels to progress (total: ${state.paintedPixels})`);
pixelBatch.pixels.forEach((p) => {
Utils.markPixelPainted(p.x, p.y, pixelBatch.regionX, pixelBatch.regionY);
});
// Update last painted position to the last pixel in the successful batch
if (pixelBatch.pixels.length > 0) {
const lastPixel = pixelBatch.pixels[pixelBatch.pixels.length - 1];
// FIXED: Use localX/localY (image-relative coordinates) instead of x/y (absolute canvas coordinates)
state.lastPaintedPosition = { x: lastPixel.localX, y: lastPixel.localY };
}
// Track painted pixels since last account switch to avoid flip-flop
state.paintedSinceSwitch = (state.paintedSinceSwitch || 0) + actuallyPaintedCount;
// IMPORTANT: Decrement charges locally to match Acc-Switch.js behavior
state.displayCharges = Math.max(0, state.displayCharges - batchSize);
state.preciseCurrentCharges = Math.max(0, state.preciseCurrentCharges - batchSize);
// Also update the global local charge model per account with bonus logic
try {
const tok = accountManager.getCurrentAccount()?.token;
const after = ChargeModel.decrement(tok, batchSize);
state.displayCharges = Math.floor(after);
state.preciseCurrentCharges = after;
if (tok) accountManager.updateAccountData(tok, { Charges: state.displayCharges });
} catch {}
state.fullChargeData = {
...state.fullChargeData,
spentSinceShot: state.fullChargeData.spentSinceShot + batchSize,
};
await updateStats();
// Update account list with new charges
await updateCurrentAccountInList();
// Progress tracking removed from UI to reduce visual clutter
Utils.performSmartSave();
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && batchSize > 0) {
const delayPerPixel = 1000 / state.paintingSpeed;
const totalDelay = Math.max(100, delayPerPixel * batchSize);
await Utils.sleep(totalDelay);
}
} else {
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
updateUI('paintingBatchFailed', 'error');
}
pixelBatch.pixels = [];
return success;
}
async function processImage() {
console.log('🚀 Starting auto-swap enabled painting workflow');
try {
// Main painting cycle - repeats until image complete or stopped
while (!state.stopFlag) {
console.log('📋 Phase 1: Starting painting session');
const paintingResult = await executePaintingSession();
if (paintingResult === 'completed') {
console.log('🎉 Image painting completed!');
state.currentPaintingColor = null; // Reset color tracking
break;
}
if (paintingResult === 'stopped') {
console.log('⏹️ Painting stopped by user');
break;
}
if (paintingResult === 'charges_depleted') {
if (CONFIG.autoBuyToggle && CONFIG.autoBuy != 'none') {
console.log('Trying to buy more charges before swapping account...');
const purchaseResult = await purchase(CONFIG.autoBuy);
if (purchaseResult == 2) {
console.log('✅ Purchase successful, continuing painting');
await updateStats();
await updateCurrentAccountInList();
if (CONFIG.autoBuy == 'paint_charges') continue;
}
else if (purchaseResult == 1) {
console.log('😭 Not enough droplets to buy more charges, swapping account.');
}
else {
console.log('🤔 Purchase failed.');
}
}
else console.log('🤫 Auto buy is disabled, wait for cooldown or swapping account.');
if (!CONFIG.autoSwap) {
// Original workflow: cooldown period
console.log('⏱️ Phase 2: Entering cooldown period (auto-swap disabled)');
const cooldownResult = await executeCooldownPeriod();
if (cooldownResult === 'stopped') {
console.log('⏹️ Cooldown stopped by user');
break;
}
// Phase 3: Regenerate token for next painting session
console.log('🔑 Phase 3: Regenerating token for next session');
const tokenResult = await regenerateTokenForNewSession();
if (!tokenResult) {
console.log('❌ Failed to regenerate token, stopping');
state.stopFlag = true;
break;
}
} else {
// Auto-swap workflow: simplified logic
console.log('🔄 Auto-swap enabled: checking account switching options');
// Retrieve fresh accounts list each time to avoid stale data
const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
console.log(`📊 Retrieved ${accounts.length} accounts from localStorage`);
if (accounts.length <= 1) {
console.log('📋 Only one account available, using standard cooldown');
const cooldownResult = await executeCooldownPeriod();
if (cooldownResult === 'stopped') break;
const tokenResult = await regenerateTokenForNewSession();
if (!tokenResult) {
state.stopFlag = true;
break;
}
} else {
// Try to find an account with enough charges to continue painting
const minRequired = Math.max(1, state.cooldownChargeThreshold || 1);
console.log(`🔎 Searching for account with ≥${minRequired} charges...`);
const switched = await selectAndSwitchToAccountWithCharges(minRequired);
if (switched) {
console.log('✅ Switched to an account with sufficient charges, continuing painting immediately');
continue;
}
// If none had enough charges, enter cooldown on the best available account
console.log('🕒 No accounts have enough charges. Entering cooldown on the account with the soonest recharge...');
const cooldownResult = await executeCooldownPeriod();
if (cooldownResult === 'stopped') break;
continue;
}
}
}
console.log('🔄 Cycle complete, starting next painting session');
}
} finally {
await finalizePaintingProcess();
}
}
// Phase 1: Execute a complete painting session using all available charges
async function executePaintingSession() {
console.log('🎨 Starting painting session - using all charges until 0');
const { width, height, pixels } = state.imageData;
const { x: startX, y: startY } = state.startPosition;
const { x: regionX, y: regionY } = state.region;
// Check if we're working with restored data by looking for existing availableColors
const isRestoredData = state.availableColors && state.availableColors.length > 0 && state.colorsChecked;
if (!isRestoredData) {
// Wait for original tiles to load if needed
const tilesReady = await overlayManager.waitForTiles(
regionX,
regionY,
width,
height,
startX,
startY,
10000 // timeout 10s
);
if (!tilesReady) {
updateUI('overlayTilesNotLoaded', 'error');
state.stopFlag = true;
return 'stopped';
}
}
let pixelBatch = null;
let skippedPixels = {
transparent: 0,
white: 0,
alreadyPainted: 0,
colorUnavailable: 0,
};
// IMPORTANT: Check charges once at start, then paint until depleted
// Use local charge model to avoid extra API calls
console.log('🔋 Checking initial charges for painting session');
const currentToken = accountManager.getCurrentAccount()?.token;
let node = ChargeModel.get(currentToken);
// One-time sync for current account if never synced
if (node && !node.lastSyncAt) {
try {
const sync = await WPlaceService.getCharges();
ChargeModel.setFromServer(currentToken, sync.charges, sync.max);
node = ChargeModel.get(currentToken);
} catch {}
}
state.displayCharges = Math.floor(node?.charges || 0);
state.preciseCurrentCharges = node?.charges || 0;
state.cooldown = CONFIG.COOLDOWN_DEFAULT;
await updateStats();
if (state.displayCharges <= 0) {
console.log('⚡ No charges available (local), skipping painting session');
return 'charges_depleted';
}
// If current account has less than threshold charges but another account
// has reached the threshold, switch BEFORE painting anything on the under-threshold account
try {
const threshold = Math.max(1, state.cooldownChargeThreshold || 1);
if (CONFIG.autoSwap && state.displayCharges < threshold) {
console.log(`🛑 Current charges (${state.displayCharges}) < threshold (${threshold}). Checking other accounts before painting...`);
const switched = await selectAndSwitchToAccountWithCharges(threshold);
if (switched) {
console.log('🔀 Switched to an account that met the threshold before painting. Restarting cycle...');
return 'charges_depleted';
}
}
} catch (e) {
console.warn('⚠️ Pre-paint switch guard failed:', e);
}
console.log(`🔋 Starting with ${state.displayCharges} charges - painting until depleted`);
// Paint pixels until we run out of charges or complete the image
try {
// Generate the actual filtered coordinates for painting (resuming from last position)
const coords = await generateCoordinatesAsync(
width,
height,
state.coordinateMode,
state.coordinateDirection,
state.coordinateSnake,
state.blockWidth,
state.blockHeight,
state.lastPosition.x,
state.lastPosition.y
);
// OPTIMIZATION: Pre-filter already painted pixels (happens only once per session)
let eligibleCoords = [];
let alreadyPaintedCount = 0;
if (!state.preFilteringDone) {
console.log('🔍 Pre-filtering already painted pixels (one-time detection for this session)...');
for (const [x, y] of coords) {
const targetPixelInfo = checkPixelEligibility(x, y);
if (!targetPixelInfo.eligible) {
if (targetPixelInfo.reason !== 'alreadyPainted') {
skippedPixels[targetPixelInfo.reason]++;
}
continue;
}
// Check if already painted (only once per session)
let absX = startX + x;
let absY = startY + y;
let adderX = Math.floor(absX / 1000);
let adderY = Math.floor(absY / 1000);
let pixelX = absX % 1000;
let pixelY = absY % 1000;
try {
const tilePixelRGBA = await overlayManager.getTilePixelColor(
regionX + adderX,
regionY + adderY,
pixelX,
pixelY
);
if (tilePixelRGBA && Array.isArray(tilePixelRGBA)) {
const mappedCanvasColor = Utils.resolveColor(
tilePixelRGBA.slice(0, 3),
state.availableColors,
!state.paintUnavailablePixels // Use same parameter as target pixel
);
const isMatch = mappedCanvasColor.id === targetPixelInfo.mappedColorId;
if (isMatch) {
alreadyPaintedCount++;
// Mark as painted in map but DO NOT increment progress counter
// Progress should only reflect actual painting sequence position
Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
continue; // Skip already painted pixels
}
}
} catch (e) {
// If we can't check, include the pixel (better to attempt than skip)
}
// Add eligible unpainted pixel to list
eligibleCoords.push([x, y, targetPixelInfo]);
}
// Mark pre-filtering as done for this session
state.preFilteringDone = true;
// Log pre-filtering results
if (alreadyPaintedCount > 0) {
console.log(`✓ Pre-filter complete: ${alreadyPaintedCount} already painted pixels detected (not added to progress counter)`);
console.log('ℹ️ This detection will not happen again until a new image/save is loaded');
console.log('📊 Progress counter only reflects actual painting sequence position');
// No need to update stats since progress wasn't changed
}
skippedPixels.alreadyPainted = alreadyPaintedCount;
} else {
// Pre-filtering already done this session, just filter for basic eligibility
console.log('🔍 Using existing pre-filter results (already done this session)');
for (const [x, y] of coords) {
const targetPixelInfo = checkPixelEligibility(x, y);
if (!targetPixelInfo.eligible) {
if (targetPixelInfo.reason !== 'alreadyPainted') {
skippedPixels[targetPixelInfo.reason]++;
}
continue;
}
// Only include pixels that haven't been marked as painted yet
let absX = startX + x;
let absY = startY + y;
let adderX = Math.floor(absX / 1000);
let adderY = Math.floor(absY / 1000);
if (!Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
eligibleCoords.push([x, y, targetPixelInfo]);
}
}
}
// Group pixels by color if color-by-color mode is enabled
let pixelsToProcess = eligibleCoords;
if (state.paintingOrder === 'color-by-color') {
console.log('🎨 Color-by-color mode enabled - grouping pixels by color');
// Group pixels by color ID
const colorGroups = new Map();
for (const [x, y, targetPixelInfo] of eligibleCoords) {
const colorId = targetPixelInfo.mappedColorId;
if (!colorGroups.has(colorId)) {
colorGroups.set(colorId, []);
}
colorGroups.get(colorId).push([x, y, targetPixelInfo]);
}
// Log color groups
console.log(`📊 Found ${colorGroups.size} different colors to paint:`);
for (const [colorId, pixels] of colorGroups.entries()) {
const colorInfo = Object.values(CONFIG.COLOR_MAP).find(c => c.id === colorId);
const colorName = colorInfo ? colorInfo.name : `Color ${colorId}`;
console.log(` 🎨 ${colorName} (ID: ${colorId}): ${pixels.length} pixels`);
}
// Process colors one by one
const sortedColorGroups = Array.from(colorGroups.entries()).sort((a, b) => a[0] - b[0]);
// If we have a current painting color, resume from that color
let startIndex = 0;
if (state.currentPaintingColor !== null) {
startIndex = sortedColorGroups.findIndex(([colorId]) => colorId === state.currentPaintingColor);
if (startIndex === -1) startIndex = 0;
console.log(`🔄 Resuming from color ID ${state.currentPaintingColor} (index ${startIndex})`);
}
// Flatten the groups starting from the current color
pixelsToProcess = [];
for (let i = startIndex; i < sortedColorGroups.length; i++) {
const [colorId, pixels] = sortedColorGroups[i];
// Avoid using spread or concat with very large arrays to prevent call stack/alloc overhead
for (let j = 0; j < pixels.length; j++) {
pixelsToProcess.push(pixels[j]);
}
}
console.log(`✅ Prepared ${pixelsToProcess.length} pixels for color-by-color painting`);
}
// Paint eligible pixels (already pre-filtered, no duplicate checks)
outerLoop: for (const [x, y, targetPixelInfo] of pixelsToProcess) {
// Track current color being painted in color-by-color mode
if (state.paintingOrder === 'color-by-color') {
if (state.currentPaintingColor !== targetPixelInfo.mappedColorId) {
state.currentPaintingColor = targetPixelInfo.mappedColorId;
const colorInfo = Object.values(CONFIG.COLOR_MAP).find(c => c.id === state.currentPaintingColor);
const colorName = colorInfo ? colorInfo.name : `Color ${state.currentPaintingColor}`;
console.log(`🎨 Now painting: ${colorName} (ID: ${state.currentPaintingColor})`);
// Update UI to show current color
const statusDiv = document.getElementById('statusDiv');
if (statusDiv) {
const colorMessage = Utils.t('currentlyPaintingColor', { colorName });
const colorIndicator = document.getElementById('currentColorIndicator');
if (colorIndicator) {
colorIndicator.textContent = colorMessage;
} else {
const indicator = document.createElement('div');
indicator.id = 'currentColorIndicator';
indicator.textContent = colorMessage;
indicator.style.cssText = 'margin-top: 8px; padding: 8px; background: rgba(255,255,255,0.1); border-radius: 6px; font-weight: bold;';
statusDiv.appendChild(indicator);
}
}
}
}
if (state.stopFlag) {
if (pixelBatch && pixelBatch.pixels.length > 0) {
console.log(`🎯 Sending last batch before stop with ${pixelBatch.pixels.length} pixels`);
await flushPixelBatch(pixelBatch);
}
state.lastPosition = { x, y };
// Show paused coordinates in UI with proper translation template
// Use last painted position if available, otherwise use current position
const pausedX = state.lastPaintedPosition.x || x;
const pausedY = state.lastPaintedPosition.y || y;
updateUI('paintingPaused', 'warning', { x: pausedX, y: pausedY });
console.log(`⏸️ Painting paused after last painted coordinates (${pausedX}, ${pausedY})`);
return 'stopped';
}
// Check if we have charges left (local count, no API call)
if (state.displayCharges <= 0) {
// console.log("Try to buy paint charges");
// await purchase("paint_charges");
await updateStats();
if (state.displayCharges <= 0) {
console.log('⚡ No charges left (local count), ending painting session');
if (pixelBatch && pixelBatch.pixels.length > 0) {
console.log(`🎯 Sending final batch with ${pixelBatch.pixels.length} pixels`);
await flushPixelBatch(pixelBatch);
}
state.lastPosition = { x, y };
return 'charges_depleted';
}
// else {
// console.log(`🔋 Charges after purchase: ${state.displayCharges}, continuing painting`);
// }
}
let absX = startX + x;
let absY = startY + y;
let adderX = Math.floor(absX / 1000);
let adderY = Math.floor(absY / 1000);
let pixelX = absX % 1000;
let pixelY = absY % 1000;
// CRITICAL FIX: Always check if pixel is already painted (both locally and on canvas)
if (Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
console.log(`⏭️ Skipping already painted pixel at (${x}, ${y}) - marked in local map`);
continue; // Skip already painted pixels
}
// REAL-TIME CANVAS CHECK: Verify against actual canvas state to prevent overpainting
try {
const existingColorRGBA = await overlayManager.getTilePixelColor(
regionX + adderX,
regionY + adderY,
pixelX,
pixelY
).catch(() => null);
if (existingColorRGBA && Array.isArray(existingColorRGBA)) {
const [er, eg, eb] = existingColorRGBA;
const existingMappedColor = Utils.resolveColor(
[er, eg, eb],
state.availableColors,
!state.paintUnavailablePixels
);
const isAlreadyCorrect = existingMappedColor.id === targetPixelInfo.mappedColorId;
if (isAlreadyCorrect) {
console.log(`✅ Pixel at (${x}, ${y}) already has correct color (${existingMappedColor.id}) - marking as painted`);
// Mark it as painted in local map but DO NOT increment progress counter
// Progress should only reflect actual painting sequence position
Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
continue; // Skip painting this pixel
}
}
} catch (e) {
// If we can't check the canvas, proceed with painting (better to attempt than skip)
console.warn(`⚠️ Could not verify canvas state for pixel (${x}, ${y}), proceeding with paint:`, e.message);
}
const targetMappedColorId = targetPixelInfo.mappedColorId;
// Set up pixel batch for new region if needed
if (
!pixelBatch ||
pixelBatch.regionX !== regionX + adderX ||
pixelBatch.regionY !== regionY + adderY
) {
if (pixelBatch && pixelBatch.pixels.length > 0) {
console.log(`🌍 Sending region-change batch with ${pixelBatch.pixels.length} pixels`);
const success = await flushPixelBatch(pixelBatch);
if (!success) {
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
return 'stopped';
}
await updateStats();
}
pixelBatch = {
regionX: regionX + adderX,
regionY: regionY + adderY,
pixels: [],
};
}
// Add pixel to batch (no need to check again - already pre-filtered)
pixelBatch.pixels.push({
x: pixelX,
y: pixelY,
color: targetMappedColorId,
localX: x,
localY: y,
});
// Send batch if it's full
const maxBatchSize = calculateBatchSize();
if (pixelBatch.pixels.length >= maxBatchSize) {
console.log(`📦 Sending batch with ${pixelBatch.pixels.length} pixels`);
const success = await flushPixelBatch(pixelBatch);
if (!success) {
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
return 'stopped';
}
pixelBatch.pixels = [];
await updateStats();
}
}
// Send final batch if any pixels remain
if (pixelBatch && pixelBatch.pixels.length > 0 && !state.stopFlag) {
console.log(`🏁 Sending final batch with ${pixelBatch.pixels.length} pixels`);
const success = await flushPixelBatch(pixelBatch);
if (!success) {
console.warn(`⚠️ Final batch failed with ${pixelBatch.pixels.length} pixels`);
}
}
// If we completed the entire coordinate loop, image is complete
return state.stopFlag ? 'stopped' : 'completed';
} finally {
// Log skip statistics for this session
console.log(`📊 Session Statistics:`);
console.log(` New pixels painted: ${state.paintedPixels - (skippedPixels.alreadyPainted || 0)}`);
console.log(` Already painted detected: ${skippedPixels.alreadyPainted}`);
console.log(` Total progress: ${state.paintedPixels}`);
console.log(` Pre-filtered - Transparent: ${skippedPixels.transparent}`);
console.log(` Pre-filtered - White: ${skippedPixels.white}`);
console.log(` Pre-filtered - Color Unavailable: ${skippedPixels.colorUnavailable}`);
}
}
// Phase 2: Execute cooldown period - wait for target charges (NO token regeneration)
async function executeCooldownPeriod() {
console.log('⏱️ Entering cooldown period - waiting for target charges');
console.log('🚫 NO token regeneration during cooldown (even if expired/invalid)');
// Check initial charges to calculate wait time
let chargeCheckCount = 0;
// const maxChargeChecks = 10; // REMOVED: No limit on API calls during cooldown
while (!state.stopFlag) {
const threshold = Math.max(1, state.cooldownChargeThreshold || 1);
const accounts = accountManager.getAllAccounts();
let anyReady = false;
let bestMs = Infinity;
let currentCharges = ChargeModel.getForCurrent()?.charges || 0;
for (const acc of accounts) {
const node = ChargeModel.get(acc.token);
if (!node) continue;
if (node.charges >= threshold) {
anyReady = true;
break;
}
const ms = ChargeModel.predictTimeToReach(acc.token, threshold);
if (ms < bestMs) bestMs = ms;
}
if (anyReady) {
console.log(`✅ Cooldown target reached locally (≥${threshold})`);
NotificationManager.maybeNotifyChargesReached(true);
await updateStats();
return 'target_reached';
}
const waitMs = Number.isFinite(bestMs) ? Math.max(1000, Math.min(bestMs, 10000)) : 10000;
updateUI('noChargesThreshold', 'warning', {
time: Utils.msToTimeText(waitMs),
threshold,
current: currentCharges,
});
await updateStats();
await Utils.sleep(waitMs);
}
return 'stopped';
}
// Phase 3: Regenerate token for new painting session
async function regenerateTokenForNewSession() {
console.log('🔑 Regenerating token for new painting session');
try {
// Force regenerate token for new session
await ensureToken(true); // forceRefresh = true
if (!getTurnstileToken()) {
console.error('❌ Failed to generate token for new session');
return false;
}
console.log('✅ Token regenerated successfully for new session');
return true;
} catch (error) {
console.error('❌ Token regeneration failed:', error);
return false;
}
}
// Finalize painting process cleanup
async function finalizePaintingProcess() {
console.log('🧹 Finalizing painting process');
if (window._chargesInterval) {
clearInterval(window._chargesInterval);
window._chargesInterval = null;
}
if (state.stopFlag) {
// Save progress when stopped to preserve painted map
Utils.saveProgress();
} else {
updateUI('paintingComplete', 'success', { count: state.paintedPixels });
state.lastPosition = { x: 0, y: 0 }; // Only reset when truly complete
Utils.saveProgress(); // Save final complete state
overlayManager.clear();
const toggleOverlayBtn = document.getElementById('toggleOverlayBtn');
if (toggleOverlayBtn) {
toggleOverlayBtn.classList.remove('active');
toggleOverlayBtn.disabled = true;
}
}
}
// Helper function to check pixel eligibility (shared by painting functions)
function checkPixelEligibility(x, y) {
const { width, height, pixels } = state.imageData;
const transparencyThreshold = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
// CRITICAL FIX: Check module availability before processing
if (!Utils || typeof Utils.isWhitePixel !== 'function') {
console.error('❌ Utils module not available for pixel eligibility check');
return {
eligible: false,
reason: 'moduleUnavailable',
};
}
const idx = (y * width + x) * 4;
const r = pixels[idx],
g = pixels[idx + 1],
b = pixels[idx + 2],
a = pixels[idx + 3];
if (!state.paintTransparentPixels && a < transparencyThreshold)
return {
eligible: false,
reason: 'transparent',
};
if (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
return {
eligible: false,
reason: 'white',
};
let targetRgb = Utils.isWhitePixel(r, g, b)
? [255, 255, 255]
: Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
const mappedTargetColorId = Utils.resolveColor(
targetRgb,
state.availableColors,
!state.paintUnavailablePixels
);
if (!state.paintUnavailablePixels && !mappedTargetColorId.id) {
return {
eligible: false,
reason: 'colorUnavailable',
r,
g,
b,
a,
mappedColorId: mappedTargetColorId.id,
};
}
return { eligible: true, r, g, b, a, mappedColorId: mappedTargetColorId.id };
}
// Helper function to skip pixel and log the reason (minimized logging)
function skipPixel(reason, id, rgb, x, y, skippedPixels) {
// Minimize logging to prevent console flooding - only log non-routine skips
if (reason !== 'transparent' && reason !== 'alreadyPainted') {
console.log(`Skipped pixel for ${reason} (id: ${id}, (${rgb.join(', ')})) at (${x}, ${y})`);
}
skippedPixels[reason]++;
}
function calculateBatchSize() {
let targetBatchSize;
// If speed control is disabled, use all available charges
if (!CONFIG.PAINTING_SPEED_ENABLED) {
targetBatchSize = state.displayCharges;
console.log(`🚀 Speed control disabled: using all ${targetBatchSize} available charges`);
return Math.max(1, targetBatchSize);
}
// CRITICAL FIX: When speed control is ENABLED, use actual available charges as the batch size
// This ensures bot sends exactly the number of pixels matching available charges
// Example: 75 charges → send 75 pixels, 14 charges → send 14 pixels
const availableCharges = state.displayCharges;
if (state.batchMode === 'random') {
// Generate random batch size within the specified range
const min = Math.max(1, state.randomBatchMin);
const max = Math.max(min, state.randomBatchMax);
targetBatchSize = Math.floor(Math.random() * (max - min + 1)) + min;
console.log(`🎲 Random batch size generated: ${targetBatchSize} (range: ${min}-${max})`);
} else {
// Normal mode - use the fixed paintingSpeed value
targetBatchSize = state.paintingSpeed;
}
// FIXED: Use actual available charges as the maximum batch size
// This matches the available pixels exactly (e.g., 75 charges = 75 pixel batch)
const finalBatchSize = Math.min(targetBatchSize, availableCharges);
console.log(`📊 Batch size: ${finalBatchSize} (target: ${targetBatchSize}, available: ${availableCharges})`);
return Math.max(1, finalBatchSize);
}
// Helper function to retry batch until success with exponential backoff
async function sendBatchWithRetry(pixels, regionX, regionY, maxRetries = MAX_BATCH_RETRIES) {
let attempt = 0;
while (attempt < maxRetries && !state.stopFlag) {
attempt++;
console.log(
`🔄 Attempting to send batch (attempt ${attempt}/${maxRetries}) for region ${regionX},${regionY} with ${pixels.length} pixels`
);
const result = await sendPixelBatch(pixels, regionX, regionY);
if (result === true) {
console.log(`✅ Batch succeeded on attempt ${attempt}`);
return true;
} else if (result === 'token_error') {
console.log(`🔑 Token error on attempt ${attempt} - no token available during processing`);
console.log(`❌ Stopping batch processing - tokens must be generated at startup/start button only`);
updateUI('captchaFailed', 'error');
await Utils.sleep(2000); // Wait longer before retrying after token failure
continue; // Continue to retry until maxRetries reached
} else if (result === 'token_regenerated') {
console.log(`🔄 Token regenerated on attempt ${attempt} after 403 error - retrying batch`);
const pausedX = state.lastPaintedPosition.x;
const pausedY = state.lastPaintedPosition.y;
updateUI('paintingPaused', 'warning', { x: pausedX, y: pausedY });
// Don't count token regeneration as a failed attempt, retry immediately
attempt--;
await Utils.sleep(500); // Brief pause before retry
continue;
} else if (result === 'token_regeneration_failed') {
console.log(`❌ Token regeneration failed on attempt ${attempt} after 403 error`);
updateUI('captchaFailed', 'error');
return false; // Stop processing if we can't get a valid token
} else if (result === 'invalid_token_error') {
console.log(`🔑 Invalid token detected on attempt ${attempt}, regenerating...`);
updateUI('captchaSolving', 'warning');
try {
await handleCaptcha(true); // Allow generation for invalid token cases
// Don't count token regeneration as a failed attempt
attempt--;
continue;
} catch (e) {
console.error(`❌ Token regeneration failed after invalid token on attempt ${attempt}:`, e);
updateUI('captchaFailed', 'error');
// Wait longer before retrying after token failure
await Utils.sleep(5000);
}
} else {
console.warn(`⚠️ Batch failed on attempt ${attempt}, retrying...`);
// Exponential backoff with jitter
const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 30000); // Max 30s
const jitter = Math.random() * 1000; // Add up to 1s random delay
await Utils.sleep(baseDelay + jitter);
}
}
if (attempt >= maxRetries) {
console.error(
`❌ Batch failed after ${maxRetries} attempts (MAX_BATCH_RETRIES=${MAX_BATCH_RETRIES}). This will stop painting to prevent infinite loops.`
);
updateUI('paintingError', 'error');
return false;
}
return false;
}
async function sendPixelBatch(pixelBatch, regionX, regionY) {
let token = getTurnstileToken();
// Don't auto-generate tokens during processing - return error if no token available
if (!token) {
console.warn('⚠️ No token available and auto-generation disabled during processing');
return 'token_error';
}
const coords = new Array(pixelBatch.length * 2);
const colors = new Array(pixelBatch.length);
for (let i = 0; i < pixelBatch.length; i++) {
const pixel = pixelBatch[i];
coords[i * 2] = pixel.x;
coords[i * 2 + 1] = pixel.y;
colors[i] = pixel.color;
}
try {
const payload = { coords, colors, t: token, fp: fpStr32 };
var wasmtoken = await createWasmToken(regionX, regionY, payload);
const res = await fetch(`https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=UTF-8', "x-pawtect-token": wasmtoken },
credentials: 'include',
body: JSON.stringify(payload),
});
if (res.status === 403) {
let data = null;
try {
data = await res.json();
} catch (_) { }
console.error('❌ 403 Forbidden. Token invalid during painting - regeneration allowed.');
// 403 errors during painting allow token regeneration per workflow requirements
console.log('� Token invalid (403) during painting - regenerating token as allowed by workflow');
setTurnstileToken(null);
createTokenPromise();
// Attempt to regenerate token immediately
const newToken = await ensureToken(true);
if (newToken) {
console.log('✅ Token regenerated after 403 error, returning regenerate signal');
return 'token_regenerated';
} else {
console.error('❌ Failed to regenerate token after 403 error');
return 'token_regeneration_failed';
}
}
const data = await res.json();
return data?.painted === pixelBatch.length;
} catch (e) {
console.error('Batch paint request failed:', e);
return false;
}
}
function saveBotSettings() {
try {
const settings = {
paintingSpeed: state.paintingSpeed,
paintingSpeedEnabled: document.getElementById('enableSpeedToggle')?.checked,
batchMode: state.batchMode, // "normal" or "random"
paintingOrder: state.paintingOrder, // "sequential" or "color-by-color"
randomBatchMin: state.randomBatchMin,
randomBatchMax: state.randomBatchMax,
cooldownChargeThreshold: state.cooldownChargeThreshold,
tokenSource: state.tokenSource, // "generator", "hybrid", or "manual"
minimized: state.minimized,
overlayOpacity: state.overlayOpacity,
blueMarbleEnabled: document.getElementById('enableBlueMarbleToggle')?.checked,
ditheringEnabled: state.ditheringEnabled,
colorMatchingAlgorithm: state.colorMatchingAlgorithm,
enableChromaPenalty: state.enableChromaPenalty,
chromaPenaltyWeight: state.chromaPenaltyWeight,
customTransparencyThreshold: state.customTransparencyThreshold,
customWhiteThreshold: state.customWhiteThreshold,
paintWhitePixels: state.paintWhitePixels,
paintTransparentPixels: state.paintTransparentPixels,
resizeSettings: state.resizeSettings,
paintUnavailablePixels: state.paintUnavailablePixels,
coordinateMode: state.coordinateMode,
coordinateDirection: state.coordinateDirection,
coordinateSnake: state.coordinateSnake,
blockWidth: state.blockWidth,
blockHeight: state.blockHeight, // Save ignore mask (as base64) with its dimensions
resizeIgnoreMask:
state.resizeIgnoreMask &&
state.resizeSettings &&
state.resizeSettings.width * state.resizeSettings.height === state.resizeIgnoreMask.length
? {
w: state.resizeSettings.width,
h: state.resizeSettings.height,
data: btoa(String.fromCharCode(...state.resizeIgnoreMask)),
}
: null, // Notifications
notificationsEnabled: state.notificationsEnabled,
notifyOnChargesReached: state.notifyOnChargesReached,
notifyOnlyWhenUnfocused: state.notifyOnlyWhenUnfocused,
notificationIntervalMinutes: state.notificationIntervalMinutes,
originalImage: state.originalImage,
ditheringMigrated: true, // Migration flag for dithering default change
blueMarbleMigrated: true, // Migration flag for blue marble default change
// NOTE: paintingMode is intentionally NOT saved - always defaults to 'auto' on page load
// Save region and startPosition for assist mode coordinate calculations
region: state.region,
startPosition: state.startPosition,
};
CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled;
// AUTO_CAPTCHA_ENABLED is always true - no need to save/load
localStorage.setItem('wplace-bot-settings', JSON.stringify(settings));
} catch (e) {
console.warn('Could not save bot settings:', e);
}
}
function loadBotSettings() {
try {
const saved = localStorage.getItem('wplace-bot-settings');
if (!saved) return;
const settings = JSON.parse(saved);
state.paintingSpeed = settings.paintingSpeed || CONFIG.PAINTING_SPEED.DEFAULT;
state.batchMode = settings.batchMode || CONFIG.BATCH_MODE; // Default to "normal"
state.paintingOrder = settings.paintingOrder || CONFIG.PAINTING_ORDER; // Default to "sequential"
state.randomBatchMin = settings.randomBatchMin || CONFIG.RANDOM_BATCH_RANGE.MIN;
state.randomBatchMax = settings.randomBatchMax || CONFIG.RANDOM_BATCH_RANGE.MAX;
state.cooldownChargeThreshold =
settings.cooldownChargeThreshold || CONFIG.COOLDOWN_CHARGE_THRESHOLD;
state.tokenSource = settings.tokenSource || CONFIG.TOKEN_SOURCE; // Default to "generator"
state.minimized = settings.minimized ?? false;
CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled ?? false;
CONFIG.AUTO_CAPTCHA_ENABLED = settings.autoCaptchaEnabled ?? false;
state.overlayOpacity = settings.overlayOpacity ?? CONFIG.OVERLAY.OPACITY_DEFAULT;
// MIGRATION: Force enable blue marble for existing users if not explicitly set
// This ensures the new default (blue marble ON) is applied to existing users
if (settings.blueMarbleMigrated !== true) {
state.blueMarbleEnabled = true;
console.log('🔄 Migrated: Blue Marble enabled (new default)');
// Save the migration flag
settings.blueMarbleMigrated = true;
localStorage.setItem('wplace-bot-settings', JSON.stringify(settings));
} else {
state.blueMarbleEnabled = settings.blueMarbleEnabled ?? CONFIG.OVERLAY.BLUE_MARBLE_DEFAULT;
}
// MIGRATION: Force reset dithering to false if not explicitly set in saved settings
// This ensures the new default (dithering OFF) is applied to existing users
if (settings.ditheringMigrated !== true) {
state.ditheringEnabled = false;
console.log('🔄 Migrated: Dithering reset to OFF (new default)');
// Save the migration flag
settings.ditheringMigrated = true;
localStorage.setItem('wplace-bot-settings', JSON.stringify(settings));
} else {
state.ditheringEnabled = settings.ditheringEnabled ?? false;
}
state.colorMatchingAlgorithm = settings.colorMatchingAlgorithm || 'lab';
state.enableChromaPenalty = settings.enableChromaPenalty ?? true;
state.chromaPenaltyWeight = settings.chromaPenaltyWeight ?? 0.15;
state.customTransparencyThreshold =
settings.customTransparencyThreshold ?? CONFIG.TRANSPARENCY_THRESHOLD;
state.customWhiteThreshold = settings.customWhiteThreshold ?? CONFIG.WHITE_THRESHOLD;
state.paintWhitePixels = settings.paintWhitePixels ?? true;
state.paintTransparentPixels = settings.paintTransparentPixels ?? false;
state.resizeSettings = settings.resizeSettings ?? null;
state.originalImage = settings.originalImage ?? null;
state.paintUnavailablePixels = settings.paintUnavailablePixels ?? CONFIG.PAINT_UNAVAILABLE;
state.coordinateMode = settings.coordinateMode ?? CONFIG.COORDINATE_MODE;
state.coordinateDirection = settings.coordinateDirection ?? CONFIG.COORDINATE_DIRECTION;
state.coordinateSnake = settings.coordinateSnake ?? CONFIG.COORDINATE_SNAKE;
state.blockWidth = settings.blockWidth ?? CONFIG.COORDINATE_BLOCK_WIDTH;
state.blockHeight = settings.blockHeight ?? CONFIG.COORDINATE_BLOCK_HEIGHT;
// Notifications
state.notificationsEnabled = settings.notificationsEnabled ?? CONFIG.NOTIFICATIONS.ENABLED;
state.notifyOnChargesReached =
settings.notifyOnChargesReached ?? CONFIG.NOTIFICATIONS.ON_CHARGES_REACHED;
state.notifyOnlyWhenUnfocused =
settings.notifyOnlyWhenUnfocused ?? CONFIG.NOTIFICATIONS.ONLY_WHEN_UNFOCUSED;
state.notificationIntervalMinutes =
settings.notificationIntervalMinutes ?? CONFIG.NOTIFICATIONS.REPEAT_MINUTES;
// NOTE: paintingMode is NOT restored - always defaults to 'auto' on page load
// Restore region and startPosition for assist mode coordinate calculations
if (settings.region) {
state.region = settings.region;
console.log('✅ Restored region from save file:', state.region);
}
if (settings.startPosition) {
state.startPosition = settings.startPosition;
console.log('✅ Restored startPosition from save file:', state.startPosition);
}
// Restore ignore mask if dims match current resizeSettings
if (
settings.resizeIgnoreMask &&
settings.resizeIgnoreMask.data &&
state.resizeSettings &&
settings.resizeIgnoreMask.w === state.resizeSettings.width &&
settings.resizeIgnoreMask.h === state.resizeSettings.height
) {
try {
const bin = atob(settings.resizeIgnoreMask.data);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
state.resizeIgnoreMask = arr;
} catch {
state.resizeIgnoreMask = null;
}
} else {
state.resizeIgnoreMask = null;
}
// Initialize coordinate generation UI
const coordinateModeSelect = document.getElementById('coordinateModeSelect');
if (coordinateModeSelect) coordinateModeSelect.value = state.coordinateMode;
const coordinateDirectionSelect = document.getElementById('coordinateDirectionSelect');
if (coordinateDirectionSelect) coordinateDirectionSelect.value = state.coordinateDirection;
const coordinateSnakeToggle = document.getElementById('coordinateSnakeToggle');
if (coordinateSnakeToggle) coordinateSnakeToggle.checked = state.coordinateSnake;
const settingsContainer = document.getElementById('wplace-settings-container');
const directionControls = settingsContainer.querySelector('#directionControls');
const snakeControls = settingsContainer.querySelector('#snakeControls');
const blockControls = settingsContainer.querySelector('#blockControls');
Utils.updateCoordinateUI({
mode: state.coordinateMode,
directionControls,
snakeControls,
blockControls,
});
const paintUnavailablePixelsToggle = document.getElementById('paintUnavailablePixelsToggle');
if (paintUnavailablePixelsToggle) {
paintUnavailablePixelsToggle.checked = state.paintUnavailablePixels;
}
const settingsPaintWhiteToggle = settingsContainer.querySelector('#settingsPaintWhiteToggle');
if (settingsPaintWhiteToggle) {
settingsPaintWhiteToggle.checked = state.paintWhitePixels;
}
const settingsPaintTransparentToggle = settingsContainer.querySelector(
'#settingsPaintTransparentToggle'
);
if (settingsPaintTransparentToggle) {
settingsPaintTransparentToggle.checked = state.paintTransparentPixels;
}
const speedSlider = document.getElementById('speedSlider');
const speedInput = document.getElementById('speedInput');
const speedValue = document.getElementById('speedValue');
if (speedSlider) speedSlider.value = state.paintingSpeed;
if (speedInput) speedInput.value = state.paintingSpeed;
if (speedValue) speedValue.textContent = `pixels`;
const enableSpeedToggle = document.getElementById('enableSpeedToggle');
if (enableSpeedToggle) enableSpeedToggle.checked = CONFIG.PAINTING_SPEED_ENABLED;
// Painting order UI initialization
const paintingOrderSelect = document.getElementById('paintingOrderSelect');
if (paintingOrderSelect) paintingOrderSelect.value = state.paintingOrder;
// Batch mode UI initialization
const batchModeSelect = document.getElementById('batchModeSelect');
if (batchModeSelect) batchModeSelect.value = state.batchMode;
const normalBatchControls = document.getElementById('normalBatchControls');
const randomBatchControls = document.getElementById('randomBatchControls');
// Show/hide appropriate controls based on batch mode
if (normalBatchControls && randomBatchControls) {
if (state.batchMode === 'random') {
normalBatchControls.style.display = 'none';
randomBatchControls.style.display = 'block';
} else {
normalBatchControls.style.display = 'block';
randomBatchControls.style.display = 'none';
}
}
const randomBatchMin = document.getElementById('randomBatchMin');
if (randomBatchMin) randomBatchMin.value = state.randomBatchMin;
const randomBatchMax = document.getElementById('randomBatchMax');
if (randomBatchMax) randomBatchMax.value = state.randomBatchMax;
// AUTO_CAPTCHA_ENABLED is always true - no toggle to set
const cooldownSlider = document.getElementById('cooldownSlider');
const cooldownInput = document.getElementById('cooldownInput');
const cooldownValue = document.getElementById('cooldownValue');
if (cooldownSlider) cooldownSlider.value = state.cooldownChargeThreshold;
if (cooldownInput) cooldownInput.value = state.cooldownChargeThreshold;
if (cooldownValue) cooldownValue.textContent = `${Utils.t('charges')}`;
const overlayOpacitySlider = document.getElementById('overlayOpacitySlider');
if (overlayOpacitySlider) overlayOpacitySlider.value = state.overlayOpacity;
const overlayOpacityValue = document.getElementById('overlayOpacityValue');
if (overlayOpacityValue)
overlayOpacityValue.textContent = `${Math.round(state.overlayOpacity * 100)}%`;
const enableBlueMarbleToggle = document.getElementById('enableBlueMarbleToggle');
if (enableBlueMarbleToggle) enableBlueMarbleToggle.checked = state.blueMarbleEnabled;
const tokenSourceSelect = document.getElementById('tokenSourceSelect');
if (tokenSourceSelect) tokenSourceSelect.value = state.tokenSource;
const colorAlgorithmSelect = document.getElementById('colorAlgorithmSelect');
if (colorAlgorithmSelect) colorAlgorithmSelect.value = state.colorMatchingAlgorithm;
const enableChromaPenaltyToggle = document.getElementById('enableChromaPenaltyToggle');
if (enableChromaPenaltyToggle) enableChromaPenaltyToggle.checked = state.enableChromaPenalty;
const chromaPenaltyWeightSlider = document.getElementById('chromaPenaltyWeightSlider');
if (chromaPenaltyWeightSlider) chromaPenaltyWeightSlider.value = state.chromaPenaltyWeight;
const chromaWeightValue = document.getElementById('chromaWeightValue');
if (chromaWeightValue) chromaWeightValue.textContent = state.chromaPenaltyWeight;
const transparencyThresholdInput = document.getElementById('transparencyThresholdInput');
if (transparencyThresholdInput)
transparencyThresholdInput.value = state.customTransparencyThreshold;
const whiteThresholdInput = document.getElementById('whiteThresholdInput');
if (whiteThresholdInput) whiteThresholdInput.value = state.customWhiteThreshold;
// Notifications UI
const notifEnabledToggle = document.getElementById('notifEnabledToggle');
if (notifEnabledToggle) notifEnabledToggle.checked = state.notificationsEnabled;
const notifOnChargesToggle = document.getElementById('notifOnChargesToggle');
if (notifOnChargesToggle) notifOnChargesToggle.checked = state.notifyOnChargesReached;
const notifOnlyUnfocusedToggle = document.getElementById('notifOnlyUnfocusedToggle');
if (notifOnlyUnfocusedToggle)
notifOnlyUnfocusedToggle.checked = state.notifyOnlyWhenUnfocused;
const notifIntervalInput = document.getElementById('notifIntervalInput');
if (notifIntervalInput) notifIntervalInput.value = state.notificationIntervalMinutes;
NotificationManager.resetEdgeTracking();
} catch (e) {
console.warn('Could not load bot settings:', e);
}
}
// Initialize Turnstile generator integration
console.log('🚀 WPlace Auto-Image with Turnstile Token Generator loaded');
console.log('🔑 Turnstile token generator: ALWAYS ENABLED (Background mode)');
console.log('🎯 Manual pixel captcha solving: Available as fallback/alternative');
console.log('📱 Turnstile widgets: DISABLED - pure background token generation only!');
// Generate fingerprint string for requests
if (!window.WPlaceTokenManager) {
console.error('❌ WPlaceTokenManager not available - dependency loading issue');
return;
}
const fpStr32 = tokenManager._randStr(32);
console.log('🔑 Generated fingerprint string for API requests');
// Function to enable file operations after initial startup setup is complete
function enableFileOperations() {
state.initialSetupComplete = true;
const loadBtn = document.querySelector('#loadBtn');
const loadFromFileBtn = document.querySelector('#loadFromFileBtn');
const uploadBtn = document.querySelector('#uploadBtn');
if (loadBtn) {
loadBtn.disabled = false;
loadBtn.title = '';
// Add a subtle animation to indicate the button is now available
loadBtn.style.animation = 'pulse 0.6s ease-in-out';
setTimeout(() => {
if (loadBtn) loadBtn.style.animation = '';
}, 600);
console.log('✅ Load Progress button enabled after initial setup');
}
if (loadFromFileBtn) {
loadFromFileBtn.disabled = false;
loadFromFileBtn.title = '';
// Add a subtle animation to indicate the button is now available
loadFromFileBtn.style.animation = 'pulse 0.6s ease-in-out';
setTimeout(() => {
if (loadFromFileBtn) loadFromFileBtn.style.animation = '';
}, 600);
console.log('✅ Load from File button enabled after initial setup');
}
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.title = '';
// Add a subtle animation to indicate the button is now available
uploadBtn.style.animation = 'pulse 0.6s ease-in-out';
setTimeout(() => {
if (uploadBtn) uploadBtn.style.animation = '';
}, 600);
console.log('✅ Upload Image button enabled after initial setup');
}
// Enable Load Extracted button at the same time as Upload button
const loadExtractedBtn = document.getElementById('loadExtractedBtn');
if (loadExtractedBtn) {
loadExtractedBtn.disabled = false;
loadExtractedBtn.title = '';
console.log('✅ Load Extracted button enabled after initial setup');
}
// Show a notification that file operations are now available
Utils.showAlert(Utils.t('fileOperationsAvailable'), 'success');
}
// Optimized token initialization with better timing and error handling
async function initializeTokenGenerator() {
// Skip if already have valid token
if (isTokenValid()) {
console.log('✅ Valid token already available, skipping initialization');
updateUI('tokenReady', 'success');
enableFileOperations(); // Enable file operations since initial setup is complete
return;
}
try {
console.log('🔧 Initializing Turnstile token generator...');
updateUI('initializingToken', 'default');
console.log('Attempting to load Turnstile script...');
await Utils.loadTurnstile();
console.log('Turnstile script loaded. Attempting to generate token...');
// Use TokenManager's handleCaptchaWithRetry method instead
const token = await tokenManager.handleCaptchaWithRetry();
if (token) {
setTurnstileToken(token);
console.log('✅ Startup token generated successfully');
updateUI('tokenReady', 'success');
Utils.showAlert(Utils.t('tokenGeneratorReady'), 'success');
enableFileOperations(); // Enable file operations since initial setup is complete
} else {
console.warn(
'⚠️ Startup token generation failed (no token received), will retry when needed'
);
updateUI('tokenRetryLater', 'warning');
// Still enable file operations even if initial token generation fails
// Users can load progress and use manual/hybrid modes
enableFileOperations();
}
} catch (error) {
console.error('❌ Critical error during Turnstile initialization:', error); // More specific error
updateUI('tokenRetryLater', 'warning');
// Still enable file operations even if initial setup fails
// Users can load progress and use manual/hybrid modes
enableFileOperations();
// Don't show error alert for initialization failures, just log them
}
}
// Load theme preference immediately on startup before creating UI
loadThemePreference();
applyTheme();
var pawtect_chunk = null;
//find module if pawtect_chunk is null
pawtect_chunk ??= await findTokenModule("pawtect_wasm_bg.wasm");
async function createWasmToken(regionX, regionY, payload) {
try {
// Load the Pawtect module and WASM
const mod = await import(new URL('/_app/immutable/chunks/' + pawtect_chunk, location.origin).href);
let wasm;
try {
wasm = await mod._();
console.log('✅ WASM initialized successfully');
} catch (wasmError) {
console.error('❌ WASM initialization failed:', wasmError);
return null;
}
try {
try {
const me = await fetch(`https://backend.wplace.live/me`, { credentials: 'include' }).then(r => r.ok ? r.json() : null);
if (me?.id) {
mod.i(me.id);
console.log('✅ user ID set:', me.id);
}
} catch { }
} catch (userIdError) {
console.log('⚠️ Error setting user ID:', userIdError.message);
}
try {
const testUrl = `https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`;
if (mod.r) {
mod.r(testUrl);
console.log('✅ Request URL set:', testUrl);
} else {
console.log('⚠️ request_url function (mod.r) not available');
}
} catch (urlError) {
console.log('⚠️ Error setting request URL:', urlError.message);
}
// Create test payload
console.log('📝 payload:', payload);
// Encode payload
const enc = new TextEncoder();
const dec = new TextDecoder();
const bodyStr = JSON.stringify(payload);
const bytes = enc.encode(bodyStr);
console.log('📏 Payload size:', bytes.length, 'bytes');
console.log('📄 Payload string:', bodyStr);
// Allocate WASM memory with validation
let inPtr;
try {
if (!wasm.__wbindgen_malloc) {
console.error('❌ __wbindgen_malloc function not found');
return null;
}
inPtr = wasm.__wbindgen_malloc(bytes.length, 1);
console.log('✅ WASM memory allocated, pointer:', inPtr);
// Copy data to WASM memory
const wasmBuffer = new Uint8Array(wasm.memory.buffer, inPtr, bytes.length);
wasmBuffer.set(bytes);
console.log('✅ Data copied to WASM memory');
} catch (memError) {
console.error('❌ Memory allocation error:', memError);
return null;
}
// Call the WASM function
console.log('🚀 Calling get_pawtected_endpoint_payload...');
let outPtr, outLen, token;
try {
const result = wasm.get_pawtected_endpoint_payload(inPtr, bytes.length);
console.log('✅ Function called, result type:', typeof result, result);
if (Array.isArray(result) && result.length === 2) {
[outPtr, outLen] = result;
console.log('✅ Got output pointer:', outPtr, 'length:', outLen);
// Decode the result
const outputBuffer = new Uint8Array(wasm.memory.buffer, outPtr, outLen);
token = dec.decode(outputBuffer);
console.log('✅ Token decoded successfully');
} else {
console.error('❌ Unexpected function result format:', result);
return null;
}
} catch (funcError) {
console.error('❌ Function call error:', funcError);
console.error('Stack trace:', funcError.stack);
return null;
}
// Cleanup memory
try {
if (wasm.__wbindgen_free && outPtr && outLen) {
wasm.__wbindgen_free(outPtr, outLen, 1);
console.log('✅ Output memory freed');
}
if (wasm.__wbindgen_free && inPtr) {
wasm.__wbindgen_free(inPtr, bytes.length, 1);
console.log('✅ Input memory freed');
}
} catch (cleanupError) {
console.log('⚠️ Cleanup warning:', cleanupError.message);
}
// Display results
console.log('');
console.log('🎉 SUCCESS!');
console.log('🔑 Full token:');
console.log(token);
return token;
} catch (error) {
console.error('❌ Failed to generate fp parameter:', error);
return null;
}
}
async function findTokenModule(str) {
console.log('🔎 Searching for wasm Module...');
const links = Array.from(document.querySelectorAll('link[rel="modulepreload"][href$=".js"]'));
for (const link of links) {
try {
const url = new URL(link.getAttribute("href"), location.origin).href;
const code = await fetch(url).then(r => r.text());
if (code.includes(str)) {
console.log('✅ Found wasm Module...');
return url.split('/').pop();
}
} catch (e) { /* ignore individual fetch errors */ }
}
console.error('❌ Could not find Pawtect chunk among preloaded modules');
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 = await fetch("https://backend.wplace.live/purchase", {
method: "POST",
headers: {
"Content-Type": "text/plain;charset=UTF-8"
},
body: JSON.stringify(payload),
credentials: "include"
});
// POST request completed
const { droplets: newDroplets } = await WPlaceService.getCharges();
if (droplets != newDroplets) {
console.log("Successfully bought", amounts * chargeMultiplier, type.replace('_', ' '), ".");
return 2;
} else {
console.log("Failed to buy charges");
return 1;
}
} catch (e) {
console.error("An error occurred during the purchase:", e);
return 0;
}
}
async function swapAccountTrigger(token) {
// STRICT GUARD: Only allow account switching during active painting sessions OR controlled refresh
if (!state.running && !state.isFetchingAllAccounts) {
console.warn('🔒 Account switching blocked - only allowed during active painting or controlled refresh');
console.warn('🔒 Current state.running:', state.running, 'state.isFetchingAllAccounts:', state.isFetchingAllAccounts);
return false;
}
localStorage.removeItem("lp");
if (!token) {
console.error('❌ Cannot swap account: token is null or undefined');
return false;
}
console.log(`🔄 Triggering account swap with token: ${token.substring(0, 20)}...`);
console.log('📤 Sending setCookie message to extension...');
try {
window.postMessage({
source: 'my-userscript',
type: 'setCookie',
value: token
}, '*');
console.log('✅ setCookie message sent successfully');
} catch (error) {
console.error('❌ Failed to send setCookie message:', error);
return false;
}
// Wait for background confirmation that cookie was set
const confirmed = await new Promise((resolve) => {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
console.warn('⚠️ cookieSet confirmation timeout');
resolve(false);
}
}, 10000);
function onMessage(event) {
if (event.source !== window) return;
const data = event.data || {};
if (data.type === 'cookieSet') {
if (!settled) {
settled = true;
clearTimeout(timer);
window.removeEventListener('message', onMessage);
resolve(true);
}
}
}
window.addEventListener('message', onMessage);
});
if (confirmed) {
console.log('✅ Cookie set confirmed');
} else {
console.warn('⚠️ Proceeding without cookie confirmation');
}
return true;
}
async function getAccounts() {
return new Promise((resolve, reject) => {
console.log("Requesting accounts from extension...");
// Ask extension for accounts
window.postMessage({
source: "my-userscript",
type: "getAccounts"
}, "*");
function handler(event) {
if (event.source !== window) return;
if (event.data.source !== "extension") return;
if (event.data.type === "accountsData") {
// Remove listener when we get the response
window.removeEventListener("message", handler);
try {
localStorage.setItem("accounts", JSON.stringify(event.data.accounts));
console.log("✅ Accounts saved to localStorage:", event.data.accounts);
} catch (e) {
console.error("❌ Failed to save accounts:", e);
}
resolve(event.data.accounts);
}
}
window.addEventListener("message", handler);
});
}
async function fetchAllAccountDetails() {
if (state.isFetchingAllAccounts) {
Utils.showAlert("Already fetching account details.", "warning");
return;
}
state.isFetchingAllAccounts = true;
const refreshBtn = document.getElementById('refreshAllAccountsBtn');
if (refreshBtn) {
refreshBtn.innerHTML = '';
refreshBtn.disabled = true;
}
const accountsListArea = document.getElementById('accountsListArea');
if (accountsListArea) {
accountsListArea.innerHTML = `
Loading accounts...
`;
}
try {
// First, get accounts from the extension
console.log(`🔄 [FETCH] Requesting accounts from extension...`);
try {
await getAccounts();
console.log(`✅ [FETCH] Successfully retrieved accounts from extension`);
} catch (error) {
console.warn(`⚠️ [FETCH] Failed to get accounts from extension:`, error);
// Continue anyway in case we have cached accounts
}
// Load accounts using the new AccountManager
await accountManager.loadAccounts();
console.log(`✅ [FETCH] Loaded ${accountManager.getAccountCount()} accounts from storage`);
// Debug: Check if we actually have accounts
if (accountManager.getAccountCount() === 0) {
console.warn(`⚠️ [FETCH] No accounts found in storage. Check localStorage 'accounts' and chrome.storage 'infoAccounts'`);
// Check localStorage for accounts
const localStorageAccounts = JSON.parse(localStorage.getItem("accounts")) || [];
console.log(`📋 [DEBUG] localStorage accounts:`, localStorageAccounts);
// Render empty state and return early
renderAccountsList();
return;
}
// Now fetch fresh data for each account
const accounts = accountManager.getAllAccounts();
if (accounts.length > 0) {
console.log(`🔄 [FETCH] Fetching fresh data for ${accounts.length} accounts...`);
// Remember the current account so we can switch back
const originalCurrentAccount = accounts.find(acc => acc.isCurrent);
for (let i = 0; i < accounts.length; i++) {
const account = accounts[i];
console.log(`📊 [FETCH] Fetching data for account ${i + 1}: ${account.displayName}`);
try {
// Switch to this account temporarily to fetch its data
console.log(`🔄 [FETCH] Switching to ${account.displayName} to fetch fresh data...`);
await switchToSpecificAccount(account.token, account.displayName);
// await Utils.sleep(500); // Small delay to ensure switch takes effect
// Fetch fresh account details
const accountData = await WPlaceService.getCharges();
const accountInfo = await WPlaceService.fetchCheck();
// Update account with fresh data
accountManager.updateAccountData(account.token, {
ID: accountData.id || accountInfo.ID,
Charges: Math.floor(accountData.charges || 0),
Max: Math.floor(accountData.max || 0),
Droplets: Math.floor(accountData.droplets || 0),
displayName: accountInfo.Username || accountInfo.name || account.displayName
});
console.log(`✅ [FETCH] Updated ${account.displayName}: ⚡${Math.floor(accountData.charges)}/${Math.floor(accountData.max)} 💧${Math.floor(accountData.droplets)}`);
} catch (error) {
console.warn(`⚠️ [FETCH] Failed to fetch data for ${account.displayName}:`, error);
}
}
// Switch back to the original current account if there was one
if (originalCurrentAccount) {
console.log(`🔙 [FETCH] Switching back to original current account: ${originalCurrentAccount.displayName}`);
try {
await switchToSpecificAccount(originalCurrentAccount.token, originalCurrentAccount.displayName);
//await Utils.sleep(300);
// Mark it as current again and sync index
accountManager.updateAccountData(originalCurrentAccount.token, {
isCurrent: true
});
const list = accountManager.getAllAccounts();
const idxOriginal = list.findIndex(acc => acc.token === originalCurrentAccount.token);
if (idxOriginal !== -1 && typeof accountManager.setCurrentIndex === 'function') {
accountManager.setCurrentIndex(idxOriginal);
state.accountIndex = idxOriginal;
}
} catch (error) {
console.warn(`⚠️ [FETCH] Failed to switch back to original account:`, error);
}
}
console.log(`🎯 [FETCH] Completed fetching fresh data for all accounts`);
try { ChargeModel.seedFromAccounts(accountManager.getAllAccounts()); } catch {}
}
// Render the accounts list with fresh data
renderAccountsList();
} catch (error) {
console.error('❌ [FETCH] Error fetching account details:', error);
if (accountsListArea) {
accountsListArea.innerHTML = `
Error loading accounts.
`;
}
} finally {
state.isFetchingAllAccounts = false;
if (refreshBtn) {
refreshBtn.innerHTML = '';
refreshBtn.disabled = false;
}
}
}
// Function to update current account charges in the account list
async function updateCurrentAccountInList() {
if (accountManager.getAccountCount() === 0) return;
try {
const current = accountManager.getCurrentAccount();
const node = ChargeModel.get(current?.token);
if (!current || !node) return;
state.displayCharges = Math.floor(node.charges || 0);
state.preciseCurrentCharges = node.charges || 0;
await updateStats();
// Refresh server-backed fields (droplets/max) to avoid stale/incorrect UI
try {
const live = await WPlaceService.getCharges();
accountManager.updateAccountData(current.token, {
Charges: Math.floor(node.charges || 0),
Max: Math.floor(live.max || node.max || current.Max || 0),
Droplets: Math.floor(live.droplets || 0)
});
} catch {}
renderAccountsList();
} catch (e) {
console.warn('⚠️ updateCurrentAccountInList failed:', e);
}
}
// Function to update current account spotlight when switching during painting
async function updateCurrentAccountSpotlight() {
if (accountManager.getAccountCount() === 0) return;
try {
const currentAccountData = await WPlaceService.getCharges();
console.log("Current account after switch:", currentAccountData);
console.log(`🔍 Switched to account with ID: ${currentAccountData.id}`);
const accounts = accountManager.getAllAccounts();
const idx = accounts.findIndex(acc => acc.ID === currentAccountData.id);
if (idx !== -1) {
const currentAccount = accounts[idx];
const currentAccountInfo = await WPlaceService.fetchCheck();
// Sync manager index and flags to actual account
if (typeof accountManager.setCurrentIndex === 'function') {
accountManager.setCurrentIndex(idx);
} else {
accounts.forEach((acc, i) => (acc.isCurrent = i === idx));
}
accountManager.updateAccountData(currentAccount.token, {
isCurrent: true,
Charges: Math.floor(currentAccountData.charges),
Max: Math.floor(currentAccountData.max),
Droplets: Math.floor(currentAccountData.droplets),
displayName: currentAccountInfo.Username || currentAccountInfo.name || currentAccount.displayName
});
// Mirror to UI state for consistency
state.displayCharges = Math.floor(currentAccountData.charges);
state.preciseCurrentCharges = currentAccountData.charges;
state.cooldown = currentAccountData.cooldown;
state.accountIndex = idx;
renderAccountsList();
console.log(`🎯 Updated current account spotlight: ${currentAccount.displayName}`);
} else {
console.warn(`⚠️ Could not find switched account with ID ${currentAccountData.id} in account list`);
}
// Re-render the account list to show new current account
renderAccountsList();
} catch (error) {
console.warn('⚠️ Failed to update account spotlight:', error);
}
}
function renderAccountsList() {
const accountsListArea = document.getElementById('accountsListArea');
if (!accountsListArea) return;
accountsListArea.innerHTML = '';
const accounts = accountManager.getAllAccounts();
console.log(`🔍 [RENDER] Rendering ${accounts.length} accounts`);
// Debug: Log account data to see what we're working with
accounts.forEach((account, index) => {
console.log(`📊 [RENDER] Account ${index + 1}: ${account.displayName} - ⚡${account.Charges}/${account.Max} 💧${account.Droplets} ${account.isCurrent ? '(CURRENT)' : ''}`);
});
if (accounts.length === 0) {
// Don't show any placeholder - just leave empty
console.log(`📝 [RENDER] No accounts to display`);
return;
}
accounts.forEach((account, index) => {
const item = createAccountItem(account, index);
accountsListArea.appendChild(item);
});
console.log(`✅ [RENDER] Successfully rendered ${accounts.length} accounts`);
}
function createAccountItem(account, index) {
const item = document.createElement('div');
// Determine if this is the current account
const isCurrentAccount = account.isCurrent;
const isNextInSequence = accountManager.currentIndex !== -1 &&
((accountManager.currentIndex + 1) % accountManager.getAccountCount()) === index;
let itemClasses = 'wplace-account-item';
if (isCurrentAccount) {
itemClasses += ' current';
} else if (isNextInSequence) {
itemClasses += ' next-in-sequence';
}
item.className = itemClasses;
// Create ordering number element with 1-based index for user display
const orderNumber = document.createElement('div');
orderNumber.className = 'wplace-account-number';
orderNumber.textContent = index + 1; // Show 1-based index for user-friendly display
// Add visual indicator for current position in sequence
if (isCurrentAccount) {
orderNumber.style.background = '#2ecc71'; // Green color
orderNumber.style.color = 'white';
orderNumber.style.boxShadow = '0 0 10px rgba(46, 204, 113, 0.8)';
} else if (isNextInSequence) {
orderNumber.style.background = '#f39c12'; // Orange color for next
orderNumber.style.color = 'white';
orderNumber.style.boxShadow = '0 0 8px rgba(243, 156, 18, 0.6)';
}
// Create account details
const details = document.createElement('div');
details.className = 'wplace-account-details';
const accountName = document.createElement('div');
accountName.className = 'wplace-account-name';
const displayName = account.displayName || account.name || `Account ${index + 1}`;
accountName.textContent = displayName;
accountName.title = displayName;
const accountStats = document.createElement('div');
accountStats.className = 'wplace-account-stats';
// Safely handle undefined values
const charges = account.Charges !== undefined ? Math.floor(account.Charges) : 0;
const max = account.Max !== undefined ? Math.floor(account.Max) : 0;
const droplets = account.Droplets !== undefined ? Math.floor(account.Droplets) : 0;
accountStats.innerHTML = `
${charges}/${max} ${droplets}
`;
details.appendChild(accountName);
details.appendChild(accountStats);
// Add status indicators
const status = document.createElement('div');
status.className = 'wplace-account-status';
if (isCurrentAccount) {
status.innerHTML = '';
} else if (isNextInSequence) {
status.innerHTML = '';
}
item.appendChild(orderNumber);
item.appendChild(details);
item.appendChild(status);
return item;
}
// SIMPLIFIED ACCOUNT SWITCHING
async function switchToNextAccount(accounts) {
console.log(`🔄 [SWITCH] Starting account switch`);
// Debounce rapid consecutive switches when no painting happened
try {
const now = Date.now();
const last = state.lastSwitchAt || 0;
const minGap = state.minMsBetweenSwitches || 3000;
const painted = state.paintedSinceSwitch || 0;
if (now - last < minGap && painted === 0) {
console.log(`⏳ [SWITCH] Debounced rapid switch (Δ${now - last}ms < ${minGap}ms and paintedSinceSwitch=${painted}).`);
return false;
}
} catch {}
// Validate we have accounts
if (accountManager.getAccountCount() === 0) {
console.error('❌ No accounts available for switching');
return false;
}
// Capture the pre-switch (current) account to detect stale reads
const preSwitchAccount = accountManager.getCurrentAccount();
const previousKnownId = preSwitchAccount?.ID || null;
// Get next account using simplified manager
const nextAccount = accountManager.switchToNext();
if (!nextAccount) {
console.error('❌ Failed to get next account');
return false;
}
console.log(`🎯 [SWITCH] Switching to: ${nextAccount.displayName} (index ${accountManager.currentIndex})`);
// Ensure token exists
if (!nextAccount.token) {
console.error(`❌ Missing token for account: ${nextAccount.displayName}`);
return false;
}
console.log(`� [SWITCH] Using token: ${nextAccount.token.substring(0, 20)}...`);
// Perform the account switch
try {
await swapAccountTrigger(nextAccount.token);
// Update account index for backward compatibility
state.accountIndex = accountManager.currentIndex;
console.log(`✅ [SWITCH] Successfully switched to ${nextAccount.displayName}`);
// Verify backend reflects the new account to avoid stale reads
// Strategy: poll /me until we see an ID different from the pre-switch account's ID
// Only then accept and (if needed) assign the next account's ID.
let verifiedId = null;
for (let attempt = 1; attempt <= 8 && !verifiedId; attempt++) {
try {
const currentAccountData = await WPlaceService.getCharges();
const curId = currentAccountData?.id;
if (!curId) {
await Utils.sleep(500);
continue;
}
if (previousKnownId && curId === previousKnownId) {
console.log(`⏳ [SWITCH] Still seeing previous ID ${curId} (attempt ${attempt}/8), waiting...`);
await Utils.sleep(500);
continue;
}
// If we had a stored ID for the target and it's different from what we see now,
// prefer the live value (curId) and update the stored ID.
if (nextAccount.ID && nextAccount.ID !== curId) {
console.log(`🔁 [SWITCH] Updating stored ID for ${nextAccount.displayName}: ${nextAccount.ID} → ${curId}`);
}
verifiedId = curId;
break;
} catch (e) {
await Utils.sleep(500);
}
}
if (!verifiedId) {
console.warn('⚠️ [SWITCH] Could not verify account change via /me after cookie set; skipping ID update but proceeding cautiously.');
} else {
// Persist verified ID for the next account
if (nextAccount.ID !== verifiedId) {
accountManager.updateAccountData(nextAccount.token, { ID: verifiedId });
}
}
// Update the account status and UI after successful switch
await updateCurrentAccountInList();
// Record switch time and reset painted counter to prevent rapid bouncing
state.lastSwitchAt = Date.now();
state.paintedSinceSwitch = 0;
return true;
} catch (error) {
console.error('❌ [SWITCH] Account switch failed:', error);
return false;
}
}
// SIMPLIFIED helper function for specific account switching
async function switchToSpecificAccount(token, accountName) {
console.log(`🔄 [SPECIFIC SWITCH] Attempting to switch to account: ${accountName}`);
// Debounce rapid consecutive switches when no painting happened
try {
const now = Date.now();
const last = state.lastSwitchAt || 0;
const minGap = state.minMsBetweenSwitches || 3000;
const painted = state.paintedSinceSwitch || 0;
if (now - last < minGap && painted === 0) {
console.log(`⏳ [SPECIFIC SWITCH] Debounced rapid switch (Δ${now - last}ms < ${minGap}ms and paintedSinceSwitch=${painted}).`);
return false;
}
} catch {}
if (!token) {
console.error('❌ [SPECIFIC SWITCH] Missing token');
return false;
}
console.log(`🔑 [SPECIFIC SWITCH] Using token: ${token.substring(0, 20)}...`);
// Capture previous account ID to detect stale responses
let previousId = null;
try {
const prev = await WPlaceService.getCharges();
previousId = prev?.id || null;
} catch {}
const ok = await swapAccountTrigger(token);
if (!ok) {
console.error('❌ [SPECIFIC SWITCH] Cookie confirmation failed');
return false;
}
// Poll /me until it reflects a different ID than the previous account
let verifiedId = null;
for (let attempt = 1; attempt <= 8 && !verifiedId; attempt++) {
try {
const currentAccountData = await WPlaceService.getCharges();
const curId = currentAccountData?.id;
if (!curId) {
await Utils.sleep(500);
continue;
}
if (previousId && curId === previousId) {
console.log(`⏳ [SPECIFIC SWITCH] Still seeing previous ID ${curId} (attempt ${attempt}/8), waiting...`);
await Utils.sleep(500);
continue;
}
verifiedId = curId;
break;
} catch {
await Utils.sleep(500);
}
}
if (!verifiedId) {
console.warn('⚠️ [SPECIFIC SWITCH] Could not verify account change via /me; proceeding.');
} else {
// Persist ID and mark as current in AccountManager
accountManager.updateAccountData(token, { ID: verifiedId, isCurrent: true });
}
// Sync manager index to the token we explicitly switched to
try {
const list = accountManager.getAllAccounts();
const idxByToken = list.findIndex(acc => acc.token === token);
if (idxByToken !== -1 && typeof accountManager.setCurrentIndex === 'function') {
accountManager.setCurrentIndex(idxByToken);
state.accountIndex = idxByToken;
}
} catch {}
// Fetch fresh stats for UI/state
const { charges, cooldown, droplets, max } = await WPlaceService.getCharges();
try { ChargeModel.setFromServer(token, charges, max); } catch {}
state.displayCharges = Math.floor(charges);
state.preciseCurrentCharges = charges;
state.cooldown = cooldown;
Utils.performSmartSave();
await updateStats();
// Update the account status and UI after successful switch
await updateCurrentAccountSpotlight();
console.log(`✅ [SPECIFIC SWITCH] Switched to ${accountName} with ${Math.floor(charges)} charges`);
return true;
}
// Wait for dependencies before initializing UI
async function waitForDependenciesAndInitialize() {
// Wait for all global managers to be available
let attempts = 0;
const maxAttempts = 50; // 5 seconds max wait
while (attempts < maxAttempts) {
const managers = {
utilsManager: window.globalUtilsManager,
imageProcessor: window.globalImageProcessor,
overlayManager: window.globalOverlayManager,
tokenManager: window.globalTokenManager
};
const missingManagers = Object.entries(managers)
.filter(([name, manager]) => !manager)
.map(([name]) => name);
if (missingManagers.length === 0) {
console.log('✅ All global managers are available, initializing UI...');
break;
}
console.log(`⏳ Waiting for managers: ${missingManagers.join(', ')} (attempt ${attempts + 1}/${maxAttempts})`);
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (attempts >= maxAttempts) {
console.warn('⚠️ Some global managers not available after waiting, proceeding anyway...');
console.log('Available managers:', {
utilsManager: !!window.globalUtilsManager,
imageProcessor: !!window.globalImageProcessor,
overlayManager: !!window.globalOverlayManager,
tokenManager: !!window.globalTokenManager
});
}
return createUI();
}
// Helper: iterate over accounts to find one with enough charges; otherwise pick best cooldown
async function selectAndSwitchToAccountWithCharges(minRequired = 1) {
try {
const total = accountManager.getAccountCount();
if (total <= 1) return false;
const threshold = Math.max(1, minRequired || 1);
const startIdx = accountManager.currentIndex;
let candidate = null; // {token,name,idx}
let bestWait = Infinity; // ms to reach threshold
for (let step = 1; step <= total - 1; step++) {
const idx = (startIdx + step) % total;
const acc = accountManager.getAccountByIndex(idx);
if (!acc || !acc.token) continue;
const node = ChargeModel.get(acc.token);
const localCharges = Math.floor(node?.charges || 0);
console.log(`🔍 [SEARCH] Checking locally ${acc.displayName}: ⚡${localCharges}/${node?.max ?? 0}`);
if (localCharges >= threshold) {
candidate = { token: acc.token, name: acc.displayName, idx };
break;
} else {
const eta = ChargeModel.predictTimeToReach(acc.token, threshold);
if (eta < bestWait) {
bestWait = eta;
candidate = { token: acc.token, name: acc.displayName, idx };
}
}
}
if (candidate && ChargeModel.get(candidate.token)?.charges >= threshold) {
console.log(`✅ [SEARCH] Local model found eligible account: ${candidate.name}`);
const ok = await switchToSpecificAccount(candidate.token, candidate.name);
return !!ok;
}
// None eligible yet – do not switch now. Caller may enter cooldown.
if (candidate) {
console.log(`🕒 [SEARCH] No accounts meet threshold. Best candidate: ${candidate.name} in ~${Utils.msToTimeText(bestWait)}`);
} else {
console.log('🕒 [SEARCH] No candidate accounts available.');
}
return false;
} catch (e) {
console.warn('⚠️ selectAndSwitchToAccountWithCharges failed:', e);
return false;
}
}
waitForDependenciesAndInitialize().then(() => {
// Generate token automatically after UI is ready
setTimeout(initializeTokenGenerator, 1000);
// Quick initial account load from cache
setTimeout(async () => {
console.log('🔄 Initial account load from cache...');
try {
await accountManager.loadAccounts();
// Seed and start local charge model regardless of count
try {
state.chargeModel = ChargeModel;
ChargeModel.seedFromAccounts(accountManager.getAllAccounts());
ChargeModel.start();
console.log('⚡ Local ChargeModel started (tick +1 per 30s for all accounts)');
} catch (e) { console.warn('ChargeModel init failed:', e); }
if (accountManager.getAccountCount() > 0) {
console.log(`✅ Loaded ${accountManager.getAccountCount()} cached accounts`);
renderAccountsList();
} else {
console.log('📭 No cached accounts found');
}
} catch (error) {
console.warn('⚠️ Initial account load failed:', error);
}
}, 500);
// Auto-refresh account list on startup (with extension communication)
setTimeout(() => {
console.log('🔄 Auto-refreshing account list on startup...');
fetchAllAccountDetails();
}, 2000);
// Attach advanced color matching listeners (resize dialog)
const advancedInit = () => {
const chromaSlider = document.getElementById('chromaPenaltyWeightSlider');
const chromaValue = document.getElementById('chromaWeightValue');
const resetBtn = document.getElementById('resetAdvancedColorBtn');
const algoSelect = document.getElementById('colorAlgorithmSelect');
const chromaToggle = document.getElementById('enableChromaPenaltyToggle');
const transInput = document.getElementById('transparencyThresholdInput');
const whiteInput = document.getElementById('whiteThresholdInput');
const ditherToggle = document.getElementById('enableDitheringToggle');
// Ensure dithering checkbox matches state (explicit sync on init)
if (ditherToggle) {
ditherToggle.checked = state.ditheringEnabled;
console.log(`🎨 Dithering initialized: ${state.ditheringEnabled ? 'ON' : 'OFF'}`);
}
if (algoSelect)
algoSelect.addEventListener('change', (e) => {
state.colorMatchingAlgorithm = e.target.value;
saveBotSettings();
_updateResizePreview();
});
if (chromaToggle)
chromaToggle.addEventListener('change', (e) => {
state.enableChromaPenalty = e.target.checked;
saveBotSettings();
_updateResizePreview();
});
if (chromaSlider && chromaValue)
chromaSlider.addEventListener('input', (e) => {
state.chromaPenaltyWeight = parseFloat(e.target.value) || 0.15;
chromaValue.textContent = state.chromaPenaltyWeight.toFixed(2);
saveBotSettings();
_updateResizePreview();
});
if (transInput)
transInput.addEventListener('change', (e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 0 && v <= 255) {
state.customTransparencyThreshold = v;
CONFIG.TRANSPARENCY_THRESHOLD = v;
saveBotSettings();
_updateResizePreview();
}
});
if (whiteInput)
whiteInput.addEventListener('change', (e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 200 && v <= 255) {
state.customWhiteThreshold = v;
CONFIG.WHITE_THRESHOLD = v;
saveBotSettings();
_updateResizePreview();
}
});
if (ditherToggle)
ditherToggle.addEventListener('change', (e) => {
state.ditheringEnabled = e.target.checked;
saveBotSettings();
_updateResizePreview();
});
if (resetBtn)
resetBtn.addEventListener('click', () => {
state.colorMatchingAlgorithm = 'lab';
state.enableChromaPenalty = true;
state.chromaPenaltyWeight = 0.15;
state.customTransparencyThreshold = CONFIG.TRANSPARENCY_THRESHOLD = 100;
state.customWhiteThreshold = CONFIG.WHITE_THRESHOLD = 250;
saveBotSettings();
const a = document.getElementById('colorAlgorithmSelect');
if (a) a.value = 'lab';
const ct = document.getElementById('enableChromaPenaltyToggle');
if (ct) ct.checked = true;
if (chromaSlider) chromaSlider.value = 0.15;
if (chromaValue) chromaValue.textContent = '0.15';
if (transInput) transInput.value = 100;
if (whiteInput) whiteInput.value = 250;
_updateResizePreview();
Utils.showAlert(Utils.t('advancedColorSettingsReset'), 'success');
});
};
// Delay to ensure resize UI built
setTimeout(advancedInit, 500);
// Add cleanup on page unload
window.addEventListener('beforeunload', () => {
Utils.cleanupTurnstile();
});
});
})();
// === Async coordinate generation (off-main-thread) ===
async function generateCoordinatesAsync(
width,
height,
mode,
direction,
snake,
blockWidth,
blockHeight,
startFromX = 0,
startFromY = 0
) {
try {
if (typeof Worker === 'undefined') {
// Environment does not support Web Workers – fall back
return generateCoordinates(
width,
height,
mode,
direction,
snake,
blockWidth,
blockHeight,
startFromX,
startFromY
);
}
const workerCode = `self.onmessage = function(e) {
const { width, height, mode, direction, snake, blockWidth, blockHeight, startFromX, startFromY } = e.data || {};
function generate() {
let coords = [];
// Determine traversal direction
let xStart, xEnd, xStep;
let yStart, yEnd, yStep;
switch (direction) {
case 'top-left':
xStart = 0; xEnd = width; xStep = 1;
yStart = 0; yEnd = height; yStep = 1;
break;
case 'top-right':
xStart = width - 1; xEnd = -1; xStep = -1;
yStart = 0; yEnd = height; yStep = 1;
break;
case 'bottom-left':
xStart = 0; xEnd = width; xStep = 1;
yStart = height - 1; yEnd = -1; yStep = -1;
break;
case 'bottom-right':
xStart = width - 1; xEnd = -1; xStep = -1;
yStart = height - 1; yEnd = -1; yStep = -1;
break;
default:
throw new Error('Unknown direction: ' + direction);
}
if (mode === 'rows') {
let rowIndex = 0;
for (let y = yStart; y !== yEnd; y += yStep) {
if (snake && rowIndex % 2 !== 0) {
for (let x = xEnd - xStep; x !== xStart - xStep; x -= xStep) {
coords.push([x, y]);
}
} else {
for (let x = xStart; x !== xEnd; x += xStep) {
coords.push([x, y]);
}
}
rowIndex++;
}
} else if (mode === 'columns') {
let colIndex = 0;
for (let x = xStart; x !== xEnd; x += xStep) {
if (snake && colIndex % 2 !== 0) {
for (let y = yEnd - yStep; y !== yStart - yStep; y -= yStep) {
coords.push([x, y]);
}
} else {
for (let y = yStart; y !== yEnd; y += yStep) {
coords.push([x, y]);
}
}
colIndex++;
}
} else if (mode === 'circle-out') {
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
for (let r = 0; r <= maxRadius; r++) {
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
if (dist === r) coords.push([x, y]);
}
}
}
}
} else if (mode === 'circle-in') {
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
for (let r = maxRadius; r >= 0; r--) {
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
if (dist === r) coords.push([x, y]);
}
}
}
}
} else if (mode === 'blocks' || mode === 'shuffle-blocks') {
const blocks = [];
for (let by = 0; by < height; by += blockHeight) {
for (let bx = 0; bx < width; bx += blockWidth) {
const block = [];
for (let y = by; y < Math.min(by + blockHeight, height); y++) {
for (let x = bx; x < Math.min(bx + blockWidth, width); x++) {
block.push([x, y]);
}
}
blocks.push(block);
}
}
if (mode === 'shuffle-blocks') {
for (let i = blocks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = blocks[i];
blocks[i] = blocks[j];
blocks[j] = tmp;
}
}
for (const block of blocks) {
for (let i = 0; i < block.length; i++) {
coords.push(block[i]);
}
}
} else {
throw new Error('Unknown mode: ' + mode);
}
// Resume from specified position if provided
if (startFromX > 0 || startFromY > 0) {
let startIndex = -1;
for (let i = 0; i < coords.length; i++) {
const c = coords[i];
if (c[0] === startFromX && c[1] === startFromY) {
startIndex = i;
break;
}
}
if (startIndex >= 0) {
coords = coords.slice(startIndex);
}
}
return coords;
}
try {
const coords = generate();
self.postMessage({ ok: true, coords });
} catch (err) {
self.postMessage({ ok: false, error: err && (err.message || String(err)) });
}
};`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
return await new Promise((resolve) => {
const worker = new Worker(url);
const cleanup = () => {
URL.revokeObjectURL(url);
try { worker.terminate(); } catch (e) { }
};
worker.onmessage = (e) => {
const { ok, coords, error } = e.data || {};
cleanup();
if (ok && Array.isArray(coords)) {
resolve(coords);
} else {
console.warn('Coordinate worker failed, falling back to sync:', error);
resolve(
generateCoordinates(
width,
height,
mode,
direction,
snake,
blockWidth,
blockHeight,
startFromX,
startFromY
)
);
}
};
worker.onerror = (err) => {
console.warn('Coordinate worker error, falling back to sync:', err && (err.message || err));
cleanup();
resolve(
generateCoordinates(
width,
height,
mode,
direction,
snake,
blockWidth,
blockHeight,
startFromX,
startFromY
)
);
};
worker.postMessage({
width,
height,
mode,
direction,
snake,
blockWidth,
blockHeight,
startFromX,
startFromY,
});
});
} catch (e) {
console.warn('Failed to start coordinate worker, using sync generation:', e);
return generateCoordinates(
width,
height,
mode,
direction,
snake,
blockWidth,
blockHeight,
startFromX,
startFromY
);
}
}