Skip to content

Instantly share code, notes, and snippets.

@Kottonye
Last active January 5, 2026 14:07
Show Gist options
  • Select an option

  • Save Kottonye/0e460154cb9b3132c940fa2b4be52faf to your computer and use it in GitHub Desktop.

Select an option

Save Kottonye/0e460154cb9b3132c940fa2b4be52faf to your computer and use it in GitHub Desktop.
WPlace Art Exporter

WPlace Art Exporter

Note

Works on wplace.live. The exporter saves a clean PNG/WebP/JPEG with a transparent background based on the tiles you’ve cached by panning/zooming.

Note

Press Alt + E to open/close the panel. First, pan/zoom around the area you want so tiles get cached—then export.

Install

  1. First go and install Tampermonkey to your corresponding brower.
  2. Go to the Tampermonkey extension settings inside the browser extensions menu, then enable "Enable User Scripts".
  3. Click the link below.

👉 Install in Tampermonkey

How to use

  1. Go to https://wplace.live and pan/zoom around your art so tiles load.
  2. Press Alt + E to open the exporter panel.
  3. Click Select Area to Export and drag a rectangle over your art.
    —or— click Export Cached Tiles to stitch everything you’ve visited.
  4. Choose format/quality in Settings if you want (PNG/WebP/JPEG).
  5. The download will start automatically. Filenames include Blue Marble coords for reference.

Troubleshooting / FAQ

Q: Can I get banned for using this? A: Shouldn't as exporting stuff is a grey area and wasn't adressed by the creators of wplace, but, as usual, there's always a chance when you run any type of user script.

Q: It exported a blank/transparent image. A: Make sure you panned/zoomed over the area first so those tiles are cached and currently visible. Then try Select Area to Export again.

Q: The panel didn’t show up. A: Press Alt + E. If it still doesn’t, reload the page and wait a second after it loads.

Q: What are the numbers in the filename? A: They’re Blue Marble tile/pixel coordinates for the top-left of your export.

Q: Doesn't work on X browser, how do I fix? A: Try another browser. Tested ones are Google Chrome and Arc for Windows.


Comments and improvements are apreciated.

MIT License

Copyright (c) 2025 Kauã Ferreira Leal dos Santos

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


“Commons Clause” License Condition v1.0

The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.

Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.

For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.

Software: WPlace Art Exporter License: MIT Licensor: Kauã Ferreira Leal dos Santos

// ==UserScript==
// @name WPlace Art Exporter
// @namespace kotton.tools
// @version 0.5
// @description Export clean artwork from wplace with transparent background
// @author Kottonye
// @match https://wplace.live/*
// @match https://*.wplace.live/*
// @run-at document-start
// @grant none
// ==/UserScript==
(function() {
'use strict';
const TILE_SIZE = 1000;
const VIEWPORT_TILE_AGE_LIMIT = 15000;
const DEFAULT_SETTINGS = {
maxCacheSize: 100,
autoCleanup: true,
showNotifications: true,
exportFormat: 'png',
compressionQuality: 0.95,
debugMode: false,
panelPosition: 'top-right'
};
let isSelecting = false;
let selectionStart = null;
let selectionBox = null;
let overlay = null;
let cachedTiles = new Map();
let mapInstance = null;
let originalFetch = null;
let currentTileCoords = [0, 0];
let currentPixelCoords = [0, 0];
let currentViewportBounds = null;
let settings = { ...DEFAULT_SETTINGS };
function addStyles() {
if (document.getElementById('wplace-styles')) return;
const style = document.createElement('style');
style.id = 'wplace-styles';
style.textContent = `
.wplace-btn {
transition: all 0.2s ease;
cursor: pointer;
border: none;
border-radius: 5px;
font-family: Arial, sans-serif;
font-weight: 500;
}
.wplace-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.wplace-btn:active {
transform: translateY(0);
}
.wplace-close-btn {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.wplace-close-btn:hover {
background: rgba(255,255,255,0.2);
}
`;
document.head.appendChild(style);
}
function loadSettings() {
try {
const saved = localStorage.getItem('wplace-export-settings');
if (saved) {
const parsed = JSON.parse(saved);
settings = { ...DEFAULT_SETTINGS, ...parsed };
console.log('Settings loaded');
}
} catch (e) {
console.warn('Failed to load settings:', e);
}
}
function saveSettings() {
try {
localStorage.setItem('wplace-export-settings', JSON.stringify(settings));
console.log('Settings saved');
} catch (e) {
console.warn('Failed to save settings:', e);
}
}
function showNotification(message, type = 'info') {
if (!settings.showNotifications) return;
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 20px;
border-radius: 6px;
font-family: Arial, sans-serif;
font-size: 13px;
z-index: 1000002;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transition: opacity 0.3s ease;
`;
const colors = {
info: { bg: 'rgba(33, 150, 243, 0.95)', color: '#fff' },
success: { bg: 'rgba(76, 175, 80, 0.95)', color: '#fff' },
warning: { bg: 'rgba(255, 152, 0, 0.95)', color: '#fff' },
error: { bg: 'rgba(244, 67, 54, 0.95)', color: '#fff' }
};
const color = colors[type] || colors.info;
notification.style.backgroundColor = color.bg;
notification.style.color = color.color;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function manageCacheSize() {
if (cachedTiles.size <= settings.maxCacheSize) return;
const tileArray = Array.from(cachedTiles.entries())
.map(([key, data]) => ({ key, ...data }))
.sort((a, b) => a.timestamp - b.timestamp);
const tilesToRemove = tileArray.slice(0, cachedTiles.size - settings.maxCacheSize);
for (const tile of tilesToRemove) {
cachedTiles.delete(tile.key);
}
if (settings.debugMode) {
console.log(`Cache managed: removed ${tilesToRemove.length} old tiles`);
}
}
function makeDraggable(element, dragHandle) {
let isDragging = false;
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
const handle = dragHandle || element;
handle.style.cursor = 'move';
const handleMouseDown = (e) => {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = element.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
e.preventDefault();
};
const handleMouseMove = (e) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
let newLeft = startLeft + deltaX;
let newTop = startTop + deltaY;
const rect = element.getBoundingClientRect();
const minVisible = 50;
newLeft = Math.max(-rect.width + minVisible, Math.min(newLeft, window.innerWidth - minVisible));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - minVisible));
element.style.left = newLeft + 'px';
element.style.top = newTop + 'px';
element.style.right = 'auto';
element.style.bottom = 'auto';
};
const handleMouseUp = () => {
if (!isDragging) return;
isDragging = false;
document.body.style.userSelect = '';
};
handle.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
handle.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
handleMouseDown({ clientX: touch.clientX, clientY: touch.clientY, target: e.target, preventDefault: () => e.preventDefault() });
}, { passive: false });
document.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const touch = e.touches[0];
handleMouseMove({ clientX: touch.clientX, clientY: touch.clientY });
e.preventDefault();
}, { passive: false });
document.addEventListener('touchend', handleMouseUp);
}
function setupTileInterception() {
if (originalFetch) return;
originalFetch = window.fetch;
window.fetch = async function(...args) {
const response = await originalFetch.apply(this, args);
const url = args[0]?.url || args[0];
if (typeof url === 'string' &&
(url.includes('/tiles/') || url.includes('.png')) &&
url.includes('wplace')) {
try {
const clonedResponse = response.clone();
const blob = await clonedResponse.blob();
const tileMatch = url.match(/\/tiles\/(\d+)\/(\d+)\.png/) ||
url.match(/\/(\d+)\/(\d+)\.png/) ||
url.match(/tile_(\d+)_(\d+)\.png/);
if (tileMatch && blob.type.startsWith('image/')) {
const tileX = parseInt(tileMatch[1]);
const tileY = parseInt(tileMatch[2]);
const cacheKey = `${tileX},${tileY}`;
const bitmap = await createImageBitmap(blob);
cachedTiles.set(cacheKey, {
bitmap: bitmap,
tileX: tileX,
tileY: tileY,
worldMinX: tileX * TILE_SIZE,
worldMinY: tileY * TILE_SIZE,
worldMaxX: (tileX + 1) * TILE_SIZE,
worldMaxY: (tileY + 1) * TILE_SIZE,
timestamp: Date.now(),
isCurrentlyVisible: true
});
if (settings.autoCleanup) manageCacheSize();
setTimeout(updateViewportBounds, 100);
if (settings.debugMode) {
console.log(`Cached tile: ${cacheKey} (${cachedTiles.size}/${settings.maxCacheSize})`);
}
}
} catch (e) {
console.warn('Failed to cache tile:', e);
}
}
return response;
};
setInterval(updateViewportBounds, 1000);
}
function setupBluemarbleListener() {
window.addEventListener('message', (event) => {
const data = event.data;
if (data.source === 'blue-marble' && data.endpoint && data.endpoint.includes('/pixel')) {
try {
const endpoint = data.endpoint.split('?')[0].split('/').filter(t => t && !isNaN(Number(t)));
const params = new URLSearchParams(data.endpoint.split('?')[1]);
const pixelCoords = [params.get('x'), params.get('y')];
if (endpoint.length >= 2 && pixelCoords[0] && pixelCoords[1]) {
currentTileCoords = [parseInt(endpoint[0]), parseInt(endpoint[1])];
currentPixelCoords = [parseInt(pixelCoords[0]), parseInt(pixelCoords[1])];
if (settings.debugMode) {
console.log(`Blue Marble coords: Tile(${currentTileCoords[0]}, ${currentTileCoords[1]}) Pixel(${currentPixelCoords[0]}, ${currentPixelCoords[1]})`);
}
}
} catch (e) {
console.warn('Failed to parse Blue Marble coordinates:', e);
}
}
});
}
function bluemarbleToWorldCoords(tileX, tileY, pixelX, pixelY) {
return { worldX: tileX * TILE_SIZE + pixelX, worldY: tileY * TILE_SIZE + pixelY };
}
function worldToBluemarbleCoords(worldX, worldY) {
return {
tileX: Math.floor(worldX / TILE_SIZE),
tileY: Math.floor(worldY / TILE_SIZE),
pixelX: worldX % TILE_SIZE,
pixelY: worldY % TILE_SIZE
};
}
function generateBluemarbleCoords(minX, maxX, minY, maxY) {
const topLeft = worldToBluemarbleCoords(minX, minY);
return `${topLeft.tileX} ${topLeft.tileY} ${topLeft.pixelX} ${topLeft.pixelY}`;
}
// Display coordinates popup dialog
function showCoordinatesDialog(minX, maxX, minY, maxY) {
const existingDialog = document.getElementById('wplace-coords-dialog');
if (existingDialog) {
existingDialog.remove();
}
const bluemarbleCoords = generateBluemarbleCoords(minX, maxX, minY, maxY);
const width = maxX - minX;
const height = maxY - minY;
const topLeft = worldToBluemarbleCoords(minX, minY);
const bottomRight = worldToBluemarbleCoords(maxX - 1, maxY - 1);
const dialog = document.createElement('div');
dialog.id = 'wplace-coords-dialog';
dialog.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.95);
color: white;
padding: 0;
border-radius: 10px;
font-family: monospace;
z-index: 1000001;
box-shadow: 0 4px 20px rgba(0,0,0,0.7);
max-width: 600px;
border: 2px solid rgba(255,255,255,0.2);
`;
const header = document.createElement('div');
header.style.cssText = `
background: linear-gradient(135deg, #2196f3, #4caf50);
padding: 10px 15px;
border-radius: 8px 8px 0 0;
cursor: move;
user-select: none;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
justify-content: space-between;
align-items: center;
`;
header.innerHTML = `
<span style="font-weight: bold;">Export Coordinates</span>
<button class="wplace-close-btn" onclick="this.closest('#wplace-coords-dialog').remove()">✕</button>
`;
const content = document.createElement('div');
content.style.cssText = `padding: 20px;`;
content.innerHTML = `
<div style="margin-bottom: 15px; background: rgba(255,255,255,0.1); padding: 10px; border-radius: 5px;">
<div style="margin-bottom: 5px;"><strong>Selection Size:</strong> ${width} × ${height} pixels</div>
<div><strong>World Bounds:</strong> (${minX}, ${minY}) to (${maxX}, ${maxY})</div>
</div>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 8px;"><strong>Blue Marble Coordinates:</strong></div>
<div style="background: rgba(255,255,255,0.1); padding: 8px; border-radius: 5px; margin-bottom: 8px;">
<div style="font-size: 12px; opacity: 0.8; margin-bottom: 4px;">Top-Left Corner:</div>
<div style="font-weight: bold;">Tile: (${topLeft.tileX}, ${topLeft.tileY}) | Pixel: (${topLeft.pixelX}, ${topLeft.pixelY})</div>
</div>
<div style="background: rgba(255,255,255,0.1); padding: 8px; border-radius: 5px;">
<div style="font-size: 12px; opacity: 0.8; margin-bottom: 4px;">Bottom-Right Corner:</div>
<div style="font-weight: bold;">Tile: (${bottomRight.tileX}, ${bottomRight.tileY}) | Pixel: (${bottomRight.pixelX}, ${bottomRight.pixelY})</div>
</div>
</div>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 8px;"><strong>Blue Marble Template Format:</strong></div>
<input type="text" value="${bluemarbleCoords}" readonly style="width: 100%; background: rgba(255,255,255,0.1); border: 1px solid #666; color: white; padding: 8px; border-radius: 3px; font-family: monospace;" onclick="this.select()">
<div style="font-size: 11px; opacity: 0.7; margin-top: 4px;">Format: TileX TileY PixelX PixelY (paste this into Blue Marble's coordinate fields)</div>
</div>
<div style="display: flex; gap: 10px;">
<button id="copy-coords-btn" class="wplace-btn" style="flex: 1; padding: 10px; background: #28a745; color: white; font-weight: bold;">Copy Coordinates</button>
<button id="close-coords-btn" class="wplace-btn" style="flex: 1; padding: 10px; background: #dc3545; color: white; font-weight: bold;">Close</button>
</div>
<div style="margin-top: 15px; font-size: 11px; opacity: 0.7; text-align: center;">
Tip: Click the coordinate input to select all text for easy copying
</div>
`;
dialog.appendChild(header);
dialog.appendChild(content);
document.body.appendChild(dialog);
makeDraggable(dialog, header);
const copyBtn = content.querySelector('#copy-coords-btn');
const closeBtn = content.querySelector('#close-coords-btn');
copyBtn.onclick = () => {
navigator.clipboard.writeText(bluemarbleCoords).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => {
copyBtn.textContent = 'Copy Coordinates';
}, 2000);
}).catch(() => {
copyBtn.textContent = 'Copy failed';
setTimeout(() => {
copyBtn.textContent = 'Copy Coordinates';
}, 2000);
});
};
closeBtn.onclick = () => dialog.remove();
// Auto-close after 60 seconds
setTimeout(() => {
if (dialog.parentElement) {
dialog.remove();
}
}, 60000);
}
function getActualViewportBounds() {
try {
const canvas = document.querySelector('canvas[class*="map"], canvas');
if (!canvas) return null;
const recentTiles = Array.from(cachedTiles.values())
.filter(tile => Date.now() - tile.timestamp < VIEWPORT_TILE_AGE_LIMIT)
.sort((a, b) => b.timestamp - a.timestamp);
if (recentTiles.length === 0) return null;
const rect = canvas.getBoundingClientRect();
const tilesMinX = Math.min(...recentTiles.map(t => t.worldMinX));
const tilesMaxX = Math.max(...recentTiles.map(t => t.worldMaxX));
const tilesMinY = Math.min(...recentTiles.map(t => t.worldMinY));
const tilesMaxY = Math.max(...recentTiles.map(t => t.worldMaxY));
const tilesWidth = tilesMaxX - tilesMinX;
const tilesHeight = tilesMaxY - tilesMinY;
const avgScale = ((tilesWidth / rect.width) + (tilesHeight / rect.height)) / 2;
const viewportWorldWidth = rect.width * avgScale;
const viewportWorldHeight = rect.height * avgScale;
const tilesCenterX = (tilesMinX + tilesMaxX) / 2;
const tilesCenterY = (tilesMinY + tilesMaxY) / 2;
return {
minX: Math.round(tilesCenterX - viewportWorldWidth / 2),
maxX: Math.round(tilesCenterX + viewportWorldWidth / 2),
minY: Math.round(tilesCenterY - viewportWorldHeight / 2),
maxY: Math.round(tilesCenterY + viewportWorldHeight / 2),
width: Math.round(viewportWorldWidth),
height: Math.round(viewportWorldHeight)
};
} catch (e) {
return null;
}
}
function updateViewportBounds() {
currentViewportBounds = getActualViewportBounds();
if (currentViewportBounds) {
for (const [key, tile] of cachedTiles.entries()) {
const overlapsX = tile.worldMinX < currentViewportBounds.maxX && tile.worldMaxX > currentViewportBounds.minX;
const overlapsY = tile.worldMinY < currentViewportBounds.maxY && tile.worldMaxY > currentViewportBounds.minY;
const isRecent = Date.now() - tile.timestamp < VIEWPORT_TILE_AGE_LIMIT;
tile.isCurrentlyVisible = overlapsX && overlapsY && isRecent;
}
}
}
function getVisibleTiles() {
const visibleTiles = new Map();
const now = Date.now();
for (const [key, tile] of cachedTiles.entries()) {
if (now - tile.timestamp < VIEWPORT_TILE_AGE_LIMIT && tile.isCurrentlyVisible) {
visibleTiles.set(key, tile);
}
}
return visibleTiles;
}
function screenToWorldCoords(screenX, screenY) {
try {
const canvas = document.querySelector('canvas[class*="map"], canvas');
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const relX = (screenX - rect.left) / rect.width;
const relY = (screenY - rect.top) / rect.height;
if (currentViewportBounds) {
return {
worldX: Math.round(currentViewportBounds.minX + (relX * currentViewportBounds.width)),
worldY: Math.round(currentViewportBounds.minY + (relY * currentViewportBounds.height))
};
}
return { worldX: Math.round(relX * 50000), worldY: Math.round(relY * 50000) };
} catch (e) {
return null;
}
}
function createSettingsMenu() {
const existingMenu = document.getElementById('wplace-settings-menu');
if (existingMenu) {
existingMenu.remove();
return;
}
const settingsMenu = document.createElement('div');
settingsMenu.id = 'wplace-settings-menu';
settingsMenu.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.95);
color: white;
padding: 0;
border-radius: 10px;
font-family: Arial, sans-serif;
z-index: 1000003;
box-shadow: 0 4px 20px rgba(0,0,0,0.7);
max-width: 450px;
min-width: 400px;
border: 2px solid rgba(255,255,255,0.2);
`;
const header = document.createElement('div');
header.style.cssText = `
background: linear-gradient(135deg, #2196f3, #4caf50);
padding: 15px 20px;
border-radius: 8px 8px 0 0;
cursor: move;
user-select: none;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
justify-content: space-between;
align-items: center;
`;
header.innerHTML = `
<div style="font-weight: bold; font-size: 16px;">Export Settings</div>
<button class="wplace-close-btn" onclick="this.closest('#wplace-settings-menu').remove()">✕</button>
`;
const content = document.createElement('div');
content.style.cssText = `padding: 20px;`;
content.innerHTML = `
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 10px; color: #64b5f6;">Export Options</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<label style="font-size: 14px;">Export Format:</label>
<select id="export-format" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 5px 8px; border-radius: 4px;">
<option value="png" ${settings.exportFormat === 'png' ? 'selected' : ''}>PNG</option>
<option value="jpeg" ${settings.exportFormat === 'jpeg' ? 'selected' : ''}>JPEG</option>
<option value="webp" ${settings.exportFormat === 'webp' ? 'selected' : ''}>WebP</option>
</select>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<label style="font-size: 14px;">Quality:</label>
<div style="display: flex; align-items: center; gap: 10px;">
<input type="range" id="compression-quality" min="0.1" max="1" step="0.05" value="${settings.compressionQuality}" style="width: 100px;">
<span id="quality-display" style="font-size: 12px; min-width: 35px;">${Math.round(settings.compressionQuality * 100)}%</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 10px; color: #64b5f6;">Cache Settings</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<label style="font-size: 14px;">Max Cache Size:</label>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="number" id="max-cache-size" min="10" max="1000" value="${settings.maxCacheSize}" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 5px 8px; border-radius: 4px; width: 70px;">
<span style="font-size: 12px; opacity: 0.7;">tiles</span>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<label style="font-size: 14px;">Auto Cleanup:</label>
<input type="checkbox" id="auto-cleanup" ${settings.autoCleanup ? 'checked' : ''} style="transform: scale(1.2);">
</div>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 10px; color: #64b5f6;">Interface</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<label style="font-size: 14px;">Show Notifications:</label>
<input type="checkbox" id="show-notifications" ${settings.showNotifications ? 'checked' : ''} style="transform: scale(1.2);">
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<label style="font-size: 14px;">Panel Position:</label>
<select id="panel-position" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 5px 8px; border-radius: 4px;">
<option value="top-right" ${settings.panelPosition === 'top-right' ? 'selected' : ''}>Top Right</option>
<option value="top-left" ${settings.panelPosition === 'top-left' ? 'selected' : ''}>Top Left</option>
<option value="bottom-right" ${settings.panelPosition === 'bottom-right' ? 'selected' : ''}>Bottom Right</option>
<option value="bottom-left" ${settings.panelPosition === 'bottom-left' ? 'selected' : ''}>Bottom Left</option>
</select>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<label style="font-size: 14px;">Debug Mode:</label>
<input type="checkbox" id="debug-mode" ${settings.debugMode ? 'checked' : ''} style="transform: scale(1.2);">
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px; padding-top: 15px; border-top: 1px solid rgba(255,255,255,0.1);">
<button id="reset-settings" class="wplace-btn" style="flex: 1; padding: 10px; background: #ff7043; color: white; font-weight: bold;">Reset</button>
<button id="save-settings" class="wplace-btn" style="flex: 1; padding: 10px; background: #4caf50; color: white; font-weight: bold;">Save</button>
</div>
`;
settingsMenu.appendChild(header);
settingsMenu.appendChild(content);
document.body.appendChild(settingsMenu);
makeDraggable(settingsMenu, header);
const qualitySlider = content.querySelector('#compression-quality');
const qualityDisplay = content.querySelector('#quality-display');
qualitySlider.addEventListener('input', () => {
qualityDisplay.textContent = Math.round(qualitySlider.value * 100) + '%';
});
content.querySelector('#save-settings').onclick = () => {
settings.exportFormat = content.querySelector('#export-format').value;
settings.compressionQuality = parseFloat(content.querySelector('#compression-quality').value);
settings.maxCacheSize = parseInt(content.querySelector('#max-cache-size').value);
settings.autoCleanup = content.querySelector('#auto-cleanup').checked;
settings.showNotifications = content.querySelector('#show-notifications').checked;
settings.panelPosition = content.querySelector('#panel-position').value;
settings.debugMode = content.querySelector('#debug-mode').checked;
saveSettings();
showNotification('Settings saved successfully!', 'success');
settingsMenu.remove();
const panel = document.getElementById('wplace-export-panel');
if (panel) {
panel.remove();
createMainPanel();
}
};
content.querySelector('#reset-settings').onclick = () => {
if (confirm('Reset all settings to defaults?')) {
settings = { ...DEFAULT_SETTINGS };
saveSettings();
showNotification('Settings reset to defaults', 'info');
settingsMenu.remove();
const panel = document.getElementById('wplace-export-panel');
if (panel) {
panel.remove();
createMainPanel();
}
}
};
const handleEscape = (e) => {
if (e.key === 'Escape') {
settingsMenu.remove();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
function createMainPanel() {
if (document.getElementById('wplace-export-panel')) return;
addStyles();
const panel = document.createElement('div');
panel.id = 'wplace-export-panel';
const positions = {
'top-right': { top: '20px', right: '20px' },
'top-left': { top: '20px', left: '20px' },
'bottom-right': { bottom: '20px', right: '20px' },
'bottom-left': { bottom: '20px', left: '20px' }
};
const pos = positions[settings.panelPosition] || positions['top-right'];
const positionStyle = Object.entries(pos).map(([k, v]) => `${k}: ${v}`).join('; ');
panel.style.cssText = `
position: fixed;
${positionStyle};
background: rgba(0,0,0,0.95);
color: white;
padding: 0;
border-radius: 10px;
font-family: Arial, sans-serif;
z-index: 999998;
box-shadow: 0 4px 12px rgba(0,0,0,0.7);
min-width: 340px;
max-width: 400px;
border: 2px solid rgba(255,255,255,0.2);
`;
const header = document.createElement('div');
header.style.cssText = `
background: linear-gradient(135deg, #2196f3, #4caf50);
padding: 15px 20px;
border-radius: 8px 8px 0 0;
cursor: move;
user-select: none;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
justify-content: space-between;
align-items: center;
`;
header.innerHTML = `
<div>
<div style="font-weight: bold; font-size: 16px;">WPlace Art Exporter</div>
<div style="font-size: 11px; opacity: 0.8;">v0.5 • Transparent backgrounds & Blue Marble support</div>
</div>
<button class="wplace-close-btn" onclick="this.closest('#wplace-export-panel').remove()">✕</button>
`;
const content = document.createElement('div');
content.style.cssText = `padding: 20px;`;
content.innerHTML = `
<div id="wplace-export-status" style="margin-bottom: 12px; font-size: 12px; opacity: 0.8; background: rgba(255,255,255,0.1); padding: 8px; border-radius: 4px;">Ready - Navigate around to cache tiles</div>
<div style="margin-bottom: 12px; padding: 8px; background: rgba(255,255,255,0.05); border-radius: 4px;">
<div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">Cache Status:</div>
<div style="font-size: 11px; opacity: 0.6;">Total cached: <span id="total-tile-count">0</span>/<span id="max-cache-display">${settings.maxCacheSize}</span> tiles</div>
<div style="font-size: 11px; opacity: 0.6; color: #90EE90;">Visible now: <span id="visible-tile-count">0</span> tiles</div>
<div style="font-size: 11px; opacity: 0.6;">Blue Marble: (<span id="current-coords">0, 0, 0, 0</span>)</div>
</div>
<div id="viewport-info" style="margin-bottom: 12px; padding: 8px; background: rgba(255,255,255,0.05); border-radius: 4px;">
<div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">Viewport:</div>
<div id="viewport-bounds" style="font-size: 11px; opacity: 0.6;">Calculating...</div>
</div>
<div id="cached-tiles-list" style="margin-bottom: 12px; font-size: 11px; opacity: 0.6; max-height: 80px; overflow-y: auto; background: rgba(255,255,255,0.05); padding: 6px; border-radius: 4px;"></div>
<button id="export-selection" class="wplace-btn" style="display: block; width: 100%; margin: 8px 0; padding: 12px; background: #28a745; color: white; font-weight: bold;">Select Area to Export</button>
<button id="export-cached" class="wplace-btn" style="display: block; width: 100%; margin: 8px 0; padding: 12px; background: #007acc; color: white; font-weight: bold;">Export Cached Tiles</button>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 8px 0;">
<button id="settings-btn" class="wplace-btn" style="padding: 10px; background: #6c757d; color: white; font-size: 12px;">Settings</button>
<button id="clear-cache" class="wplace-btn" style="padding: 10px; background: #dc3545; color: white; font-size: 12px;">Clear</button>
</div>
<div style="margin-top: 12px; font-size: 11px; opacity: 0.6; background: rgba(255,255,255,0.05); padding: 6px; border-radius: 4px;">
<div>Press Alt+E to toggle panel</div>
<div>Drag header to move windows</div>
<div>Selection exports only your selection</div>
<div>Cached exports all visited areas</div>
</div>
`;
panel.appendChild(header);
panel.appendChild(content);
document.body.appendChild(panel);
makeDraggable(panel, header);
setInterval(() => {
const totalTileCountEl = content.querySelector('#total-tile-count');
const visibleTileCountEl = content.querySelector('#visible-tile-count');
const currentCoordsEl = content.querySelector('#current-coords');
const viewportBoundsEl = content.querySelector('#viewport-bounds');
const maxCacheDisplayEl = content.querySelector('#max-cache-display');
const tilesListEl = content.querySelector('#cached-tiles-list');
const visibleTiles = getVisibleTiles();
if (totalTileCountEl) totalTileCountEl.textContent = cachedTiles.size;
if (visibleTileCountEl) visibleTileCountEl.textContent = visibleTiles.size;
if (maxCacheDisplayEl) maxCacheDisplayEl.textContent = settings.maxCacheSize;
if (currentCoordsEl) {
currentCoordsEl.textContent = `${currentTileCoords[0]}, ${currentTileCoords[1]}, ${currentPixelCoords[0]}, ${currentPixelCoords[1]}`;
}
if (viewportBoundsEl && currentViewportBounds) {
const { width, height } = currentViewportBounds;
viewportBoundsEl.textContent = `${width}×${height}px`;
} else if (viewportBoundsEl) {
viewportBoundsEl.textContent = 'Navigate to establish bounds';
}
if (tilesListEl) {
if (cachedTiles.size > 0) {
const recentTiles = Array.from(cachedTiles.entries())
.slice(-4)
.map(([key, data]) => {
const age = Math.round((Date.now() - data.timestamp) / 1000);
const isVisible = visibleTiles.has(key) ? 'Visible' : 'Cached';
return `${isVisible}: ${key} (${age}s ago)`;
})
.join('<br>');
tilesListEl.innerHTML = `Recent tiles:<br>${recentTiles}`;
} else {
tilesListEl.innerHTML = 'No cached tiles. Navigate around the map!';
}
}
}, 1000);
content.querySelector('#export-selection').onclick = startSelection;
content.querySelector('#export-cached').onclick = exportCachedTiles;
content.querySelector('#settings-btn').onclick = createSettingsMenu;
content.querySelector('#clear-cache').onclick = () => {
if (confirm('Clear all cached tiles?')) {
cachedTiles.clear();
currentViewportBounds = null;
showNotification('Cache cleared successfully', 'success');
}
};
if (settings.showNotifications) {
showNotification('WPlace Art Exporter v0.5 ready!', 'success');
}
}
function processTileForTransparency(imageBitmap) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = imageBitmap.width;
canvas.height = imageBitmap.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageBitmap, 0, 0);
createImageBitmap(canvas).then(resolve);
});
}
function findOverlappingTiles(minX, maxX, minY, maxY, useOnlyVisible = true) {
const overlappingTiles = [];
const tilesToSearch = useOnlyVisible ? getVisibleTiles() : cachedTiles;
for (const [key, tileData] of tilesToSearch.entries()) {
const overlapsX = tileData.worldMinX < maxX && tileData.worldMaxX > minX;
const overlapsY = tileData.worldMinY < maxY && tileData.worldMaxY > minY;
if (overlapsX && overlapsY) {
overlappingTiles.push({
key, data: tileData,
overlapMinX: Math.max(minX, tileData.worldMinX),
overlapMaxX: Math.min(maxX, tileData.worldMaxX),
overlapMinY: Math.max(minY, tileData.worldMinY),
overlapMaxY: Math.min(maxY, tileData.worldMaxY)
});
}
}
return overlappingTiles;
}
async function exportFromCache(startCoords, endCoords) {
if (!startCoords || !endCoords) {
showNotification('Invalid coordinates provided', 'error');
return;
}
const minX = Math.min(startCoords.worldX, endCoords.worldX);
const maxX = Math.max(startCoords.worldX, endCoords.worldX);
const minY = Math.min(startCoords.worldY, endCoords.worldY);
const maxY = Math.max(startCoords.worldY, endCoords.worldY);
const width = maxX - minX;
const height = maxY - minY;
if (width <= 0 || height <= 0) {
showNotification('Invalid selection area', 'error');
return;
}
showCoordinatesDialog(minX, maxX, minY, maxY);
const overlappingTiles = findOverlappingTiles(minX, maxX, minY, maxY, true);
if (overlappingTiles.length === 0) {
showNotification('No visible tiles in selected area', 'warning');
return;
}
showNotification(`Exporting ${width}×${height} pixels...`, 'info');
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
for (const tileInfo of overlappingTiles) {
const tile = tileInfo.data.bitmap;
if (tile) {
const processedTile = await processTileForTransparency(tile);
const srcX = Math.max(0, minX - tileInfo.data.worldMinX);
const srcY = Math.max(0, minY - tileInfo.data.worldMinY);
const srcW = Math.min(TILE_SIZE - srcX, maxX - Math.max(minX, tileInfo.data.worldMinX));
const srcH = Math.min(TILE_SIZE - srcY, maxY - Math.max(minY, tileInfo.data.worldMinY));
const dstX = Math.max(0, tileInfo.data.worldMinX - minX);
const dstY = Math.max(0, tileInfo.data.worldMinY - minY);
if (srcW > 0 && srcH > 0) {
ctx.drawImage(processedTile, srcX, srcY, srcW, srcH, dstX, dstY, srcW, srcH);
}
}
}
canvas.toBlob(blob => {
if (!blob) {
showNotification('Failed to create image', 'error');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const bluemarbleCoords = generateBluemarbleCoords(minX, maxX, minY, maxY).replace(/\s+/g, '_');
a.href = url;
a.download = `wplace-selection-${width}x${height}-${bluemarbleCoords}-${timestamp}.${settings.exportFormat}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showNotification(`Exported ${width}×${height} pixels!`, 'success');
}, `image/${settings.exportFormat}`, settings.compressionQuality);
}
async function exportCachedTiles() {
if (cachedTiles.size === 0) {
showNotification('No cached tiles available', 'warning');
return;
}
const allTiles = Array.from(cachedTiles.values());
const minX = Math.min(...allTiles.map(t => t.worldMinX));
const maxX = Math.max(...allTiles.map(t => t.worldMaxX));
const minY = Math.min(...allTiles.map(t => t.worldMinY));
const maxY = Math.max(...allTiles.map(t => t.worldMaxY));
const canvasWidth = maxX - minX;
const canvasHeight = maxY - minY;
showCoordinatesDialog(minX, maxX, minY, maxY);
showNotification(`Exporting ${allTiles.length} cached tiles...`, 'info');
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext('2d');
for (const tileData of allTiles) {
if (tileData.bitmap) {
const processedTile = await processTileForTransparency(tileData.bitmap);
const drawX = tileData.worldMinX - minX;
const drawY = tileData.worldMinY - minY;
ctx.drawImage(processedTile, drawX, drawY);
}
}
canvas.toBlob(blob => {
if (!blob) {
showNotification('Failed to create image', 'error');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const bluemarbleCoords = generateBluemarbleCoords(minX, maxX, minY, maxY).replace(/\s+/g, '_');
a.href = url;
a.download = `wplace-cached-${canvasWidth}x${canvasHeight}-${bluemarbleCoords}-${timestamp}.${settings.exportFormat}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showNotification(`Exported ${canvasWidth}×${canvasHeight} pixels!`, 'success');
}, `image/${settings.exportFormat}`, settings.compressionQuality);
}
function createOverlay() {
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.3);
cursor: crosshair;
z-index: 999999;
display: none;
`;
selectionBox = document.createElement('div');
selectionBox.style.cssText = `
position: fixed;
border: 2px dashed #00ff00;
background: rgba(0,255,0,0.1);
display: none;
z-index: 1000000;
pointer-events: none;
`;
document.body.appendChild(overlay);
document.body.appendChild(selectionBox);
return overlay;
}
function startSelection() {
updateViewportBounds();
const overlay = createOverlay();
overlay.style.display = 'block';
isSelecting = true;
selectionStart = null;
updateStatus('Click and drag to select export area...');
}
function setupSelectionEvents() {
const overlay = createOverlay();
overlay.addEventListener('mousedown', (e) => {
if (!isSelecting) return;
selectionStart = { x: e.clientX, y: e.clientY };
selectionBox.style.display = 'block';
e.preventDefault();
});
overlay.addEventListener('mousemove', (e) => {
if (!isSelecting || !selectionStart) return;
const left = Math.min(e.clientX, selectionStart.x);
const top = Math.min(e.clientY, selectionStart.y);
const width = Math.abs(e.clientX - selectionStart.x);
const height = Math.abs(e.clientY - selectionStart.y);
Object.assign(selectionBox.style, {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
});
});
overlay.addEventListener('mouseup', async (e) => {
if (!isSelecting || !selectionStart) return;
const startCoords = screenToWorldCoords(selectionStart.x, selectionStart.y);
const endCoords = screenToWorldCoords(e.clientX, e.clientY);
overlay.style.display = 'none';
selectionBox.style.display = 'none';
isSelecting = false;
if (startCoords && endCoords) {
await exportFromCache(startCoords, endCoords);
} else {
showNotification('Could not determine coordinates', 'error');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isSelecting) {
overlay.style.display = 'none';
selectionBox.style.display = 'none';
isSelecting = false;
updateStatus('Selection cancelled');
}
});
}
function updateStatus(message) {
const statusEl = document.getElementById('wplace-export-status');
if (statusEl) {
statusEl.textContent = message;
}
if (settings.debugMode) console.log(`[WPlace Export] ${message}`);
}
function waitForMap() {
return new Promise((resolve) => {
let attempts = 0;
const checkMap = () => {
attempts++;
if (window.map && typeof window.map.getBounds === 'function') {
mapInstance = window.map;
resolve(mapInstance);
return;
}
const canvas = document.querySelector('canvas[class*="map"], canvas');
if (canvas) {
mapInstance = { container: canvas.parentElement, canvas };
resolve(mapInstance);
return;
}
if (attempts < 60) {
setTimeout(checkMap, 500);
} else {
mapInstance = { fallback: true };
resolve(mapInstance);
}
};
checkMap();
});
}
function init() {
loadSettings();
setupTileInterception();
setupBluemarbleListener();
setupSelectionEvents();
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key.toLowerCase() === 'e') {
const panel = document.getElementById('wplace-export-panel');
if (panel) {
panel.remove();
} else {
createMainPanel();
}
e.preventDefault();
}
});
const checkReady = () => {
if (document.body && (document.querySelector('canvas') || document.querySelector('#map'))) {
waitForMap().then(() => {
setTimeout(() => createMainPanel(), 1000);
console.log('WPlace Art Exporter v0.5 ready! Press Alt+E to open.');
});
} else {
setTimeout(checkReady, 500);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', checkReady);
} else {
checkReady();
}
}
init();
})();
@Ko5atel
Copy link

Ko5atel commented Jan 5, 2026

Hello. Can you fix the code so that the Select area to export function works correctly? :3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment