Greasy Fork is available in English.
Several Features Added to Ghost Template including template history, upload from URL, save all templates as zip file, and image preview toggle
// ==UserScript==
// @name GeoPixels - Ghost Template Manager
// @namespace http://tampermonkey.net/
// @version 3.7
// @description Several Features Added to Ghost Template including template history, upload from URL, save all templates as zip file, and image preview toggle
// @author ariapokoteng
// @match *://geopixels.net/*
// @match *://*.geopixels.net/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=geopixels.net
// ==/UserScript==
(function() {
'use strict';
// ========== CONFIGURATION ==========
const DEBUG_MODE = false;
const DB_NAME = 'GP_Ghost_History';
const DB_VERSION = 3;
const STORE_NAME = 'images';
// Marker Colors for Encoding
const MARKER_R = 71;
const MARKER_G = 80;
const MARKER_B = 88;
const POSITION_OFFSET = 2147483648;
let isInternalUpdate = false;
let previewActive = false;
let previewOverlay = null;
// ========== UTILITIES ==========
function gpLog(msg, data = null) {
if (!DEBUG_MODE) return;
console.log(`%c[GP Manager] ${msg}`, "color: #00ffff; background: #000; padding: 2px 4px;", data || '');
}
// Debug: Log environment info on load
gpLog("Script loaded. Environment check:", {
hasWindow: typeof window !== 'undefined',
hasUnsafeWindow: typeof unsafeWindow !== 'undefined',
windowMap: typeof window !== 'undefined' ? typeof window.map : 'N/A',
windowTurf: typeof window !== 'undefined' ? typeof window.turf : 'N/A',
unsafeWindowMap: typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.map : 'N/A',
unsafeWindowTurf: typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.turf : 'N/A'
});
/**
* Safely get a page variable, avoiding DOM element conflicts.
* In some browsers, accessing unsafeWindow.map returns the <div id="map"> element
* instead of the JavaScript map variable.
*/
function getPageVariable(varName) {
// Try window first (works in Chrome/Vivaldi)
if (typeof window !== 'undefined' && window[varName] !== undefined) {
const val = window[varName];
// Make sure it's not a DOM element when we expect an object with methods
if (varName === 'map' && val instanceof HTMLElement) {
gpLog(`window.${varName} is a DOM element, trying unsafeWindow`);
} else {
gpLog(`Found ${varName} in window`);
return val;
}
}
// Try unsafeWindow (needed in Firefox/Brave with @grant permissions)
if (typeof unsafeWindow !== 'undefined' && unsafeWindow[varName] !== undefined) {
const val = unsafeWindow[varName];
// Check if it's a DOM element when we expect the map object
if (varName === 'map' && val instanceof HTMLElement) {
gpLog(`unsafeWindow.${varName} is a DOM element, looking for alternatives`);
// Try to get the map from common Mapbox/Leaflet global patterns
// The map might be stored in a different variable or we need wrappedJSObject (Firefox)
if (typeof unsafeWindow.wrappedJSObject !== 'undefined' && unsafeWindow.wrappedJSObject[varName]) {
const wrappedVal = unsafeWindow.wrappedJSObject[varName];
if (!(wrappedVal instanceof HTMLElement)) {
gpLog(`Found ${varName} in wrappedJSObject`);
return wrappedVal;
}
}
// For Brave/Chrome with sandboxing, try accessing via page script injection
gpLog(`Attempting page context injection for ${varName}`);
return getPageVariableViaInjection(varName);
} else {
gpLog(`Found ${varName} in unsafeWindow`);
return val;
}
}
// Try wrappedJSObject directly (Firefox)
if (typeof unsafeWindow !== 'undefined' &&
typeof unsafeWindow.wrappedJSObject !== 'undefined' &&
unsafeWindow.wrappedJSObject[varName] !== undefined) {
gpLog(`Found ${varName} in wrappedJSObject`);
return unsafeWindow.wrappedJSObject[varName];
}
gpLog(`Could not find ${varName} in any scope`);
return null;
}
/**
* Get a page variable by creating a bridge in the page context.
* This is needed in Brave when @grant permissions create a sandbox.
*/
function getPageVariableViaInjection(varName) {
try {
// Create a unique ID for this retrieval
const bridgeId = `__gp_bridge_${varName}_${Date.now()}`;
// Inject a script that copies the variable to a data attribute
const script = document.createElement('script');
script.textContent = `
(function() {
if (typeof ${varName} !== 'undefined' && ${varName}) {
// Store a marker that the variable exists
document.documentElement.setAttribute('${bridgeId}', 'exists');
// For map object, we can't directly transfer it, so we'll access it differently
if ('${varName}' === 'map' && typeof ${varName}.project === 'function') {
document.documentElement.setAttribute('${bridgeId}_hasProject', 'true');
}
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
// Check if the variable exists
const exists = document.documentElement.getAttribute(bridgeId);
document.documentElement.removeAttribute(bridgeId);
document.documentElement.removeAttribute(`${bridgeId}_hasProject`);
if (exists === 'exists') {
gpLog(`${varName} exists in page context, creating proxy`);
// For the map object specifically, we need to create a proxy that executes in page context
if (varName === 'map') {
return createMapProxy();
} else if (varName === 'turf') {
return createTurfProxy();
}
}
gpLog(`${varName} not found via injection`);
return null;
} catch (e) {
gpLog(`Error in page context injection for ${varName}:`, e.message);
return null;
}
}
/**
* Create a proxy object for the map that executes methods in page context
*/
function createMapProxy() {
return {
project: function(lngLat) {
// Execute in page context and return result
const script = document.createElement('script');
const resultId = `__gp_map_result_${Date.now()}`;
script.textContent = `
(function() {
try {
const result = map.project([${lngLat[0]}, ${lngLat[1]}]);
document.documentElement.setAttribute('${resultId}', JSON.stringify({x: result.x, y: result.y}));
} catch(e) {
document.documentElement.setAttribute('${resultId}_error', e.message);
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
const resultStr = document.documentElement.getAttribute(resultId);
const errorStr = document.documentElement.getAttribute(`${resultId}_error`);
document.documentElement.removeAttribute(resultId);
document.documentElement.removeAttribute(`${resultId}_error`);
if (errorStr) {
throw new Error(errorStr);
}
return JSON.parse(resultStr);
},
on: function(event, handler) {
gpLog(`Map event listener for ${event} registered (proxy mode)`);
// Store the handler for later use
if (!this._handlers) this._handlers = {};
if (!this._handlers[event]) this._handlers[event] = [];
this._handlers[event].push(handler);
// Set up event forwarding via page script
const listenerId = `__gp_map_listener_${event}_${Date.now()}`;
const script = document.createElement('script');
script.textContent = `
(function() {
if (typeof map !== 'undefined' && map.on) {
map.on('${event}', function() {
document.documentElement.setAttribute('${listenerId}', Date.now());
});
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
// Set up mutation observer to detect attribute changes
const observer = new MutationObserver(() => {
const val = document.documentElement.getAttribute(listenerId);
if (val) {
document.documentElement.removeAttribute(listenerId);
handler();
}
});
observer.observe(document.documentElement, { attributes: true });
},
off: function(event, handler) {
gpLog(`Map event listener for ${event} removed (proxy mode)`);
// In proxy mode, we can't easily remove specific handlers
// This is a limitation of the bridge approach
},
getContainer: function() {
return document.getElementById('map');
}
};
}
/**
* Create a proxy object for turf that executes methods in page context
*/
function createTurfProxy() {
return {
toWgs84: function(mercCoords) {
const script = document.createElement('script');
const resultId = `__gp_turf_result_${Date.now()}`;
script.textContent = `
(function() {
try {
const result = turf.toWgs84([${mercCoords[0]}, ${mercCoords[1]}]);
document.documentElement.setAttribute('${resultId}', JSON.stringify(result));
} catch(e) {
document.documentElement.setAttribute('${resultId}_error', e.message);
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
const resultStr = document.documentElement.getAttribute(resultId);
const errorStr = document.documentElement.getAttribute(`${resultId}_error`);
document.documentElement.removeAttribute(resultId);
document.documentElement.removeAttribute(`${resultId}_error`);
if (errorStr) {
throw new Error(errorStr);
}
return JSON.parse(resultStr);
}
};
}
function notifyUser(title, message) {
// Use safe helper to get showAlert function
const showAlert = getPageVariable('showAlert');
if (typeof showAlert === 'function') {
showAlert(title, message);
} else {
console.log(`[${title}] ${message}`);
// Fallback alert if site's showAlert is not available
alert(`${title}: ${message}`);
}
}
function goToTemplateLocation() {
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
if (!savedCoordsStr) {
notifyUser("No Template", "No ghost image template is currently set.");
return;
}
try {
const coords = JSON.parse(savedCoordsStr);
if (typeof coords.gridX !== 'number' || typeof coords.gridY !== 'number') {
notifyUser("Error", "Invalid coordinates in template.");
return;
}
// Get goToGridLocation using safe helper
const goToGridLocation = getPageVariable('goToGridLocation');
if (typeof goToGridLocation === 'function') {
gpLog(`Teleporting to template location: ${coords.gridX}, ${coords.gridY}`);
goToGridLocation(coords.gridX, coords.gridY);
} else {
notifyUser("Error", "Navigation function not available.");
gpLog("ERROR: goToGridLocation function not found in window or unsafeWindow");
}
} catch (e) {
console.error("Failed to parse coordinates:", e);
notifyUser("Error", "Failed to read template coordinates.");
}
}
// Computes a SHA-256 fingerprint of the file content
async function computeFileHash(blob) {
const buffer = await blob.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Computes a templateId from the clean image content (without position encoding)
// This allows us to identify the same template even if it's been moved to different positions
async function computeTemplateId(blob) {
try {
const img = await loadImageToCanvas(blob);
const decoded = decodeRobustPosition(img);
if (decoded && decoded.cleanCanvas) {
// If position was encoded, use the clean canvas for ID
const cleanBlob = await new Promise(r => decoded.cleanCanvas.toBlob(r, 'image/png'));
return await computeFileHash(cleanBlob);
} else {
// No position encoding found, use original hash
return await computeFileHash(blob);
}
} catch (e) {
// On error, fall back to regular hash
return await computeFileHash(blob);
}
}
// ========== STYLES ==========
const style = document.createElement('style');
style.textContent = `
.gp-to-modal-overlay {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.75);
display: flex; align-items: center; justify-content: center; z-index: 10000;
}
.gp-to-modal-panel {
background: white; border-radius: 1rem; padding: 1.5rem;
width: 95%; max-width: 600px; max-height: 80vh;
display: flex; flex-direction: column; gap: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.gp-to-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 10px; }
.gp-to-title { font-size: 1.25rem; font-weight: bold; color: #1f2937; }
.gp-to-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px; overflow-y: auto; padding: 4px;
}
.gp-to-card {
border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;
position: relative; transition: transform 0.1s, box-shadow 0.1s;
cursor: pointer; background: #f9fafb;
}
.gp-to-card:hover { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); border-color: #3b82f6; }
.gp-to-card img { width: 100%; height: 100px; object-fit: cover; display: block; }
.gp-to-card-footer {
padding: 4px; font-size: 10px; text-align: center;
background: #fff; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gp-to-delete-btn {
position: absolute; top: 2px; right: 2px;
background: rgba(239, 68, 68, 0.9); color: white;
border: none; border-radius: 4px; width: 20px; height: 20px;
display: flex; align-items: center; justify-content: center;
font-size: 12px; cursor: pointer; z-index: 2;
}
.gp-to-delete-btn:hover { background: #dc2626; }
.gp-to-btn {
padding: 0.5rem 1rem; border-radius: 0.5rem; font-weight: 600; cursor: pointer; border: none;
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
transition: all 0.2s;
}
.gp-to-btn-blue { background-color: #3b82f6; color: white; }
.gp-to-btn-blue:hover { background-color: #2563eb; }
.gp-to-btn-green { background-color: #10b981; color: white; }
.gp-to-btn-green:hover { background-color: #059669; }
.gp-to-btn-purple { background-color: #8b5cf6; color: white; }
.gp-to-btn-purple:hover { background-color: #7c3aed; }
.gp-to-btn-red { background-color: #ef4444; color: white; }
.gp-to-btn-gray { background-color: #e5e7eb; color: #374151; }
.gp-to-btn-orange { background-color: #f97316; color: white; }
.gp-to-btn-orange:hover { background-color: #ea580c; }
.gp-to-btn-cyan { background-color: #06b6d4; color: white; border: 2px solid transparent; }
.gp-to-btn-cyan:hover { background-color: #0891b2; }
.gp-to-btn-cyan.active {
background-color: #0e7490;
border: 2px solid #fbbf24;
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.3);
}
.gp-to-preview-overlay {
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.7;
transition: opacity 0.2s;
}
`;
document.head.appendChild(style);
// ========== INDEXED DB (CACHE) ==========
const dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (e) => {
const db = e.target.result;
const txn = e.target.transaction;
let store;
if (!db.objectStoreNames.contains(STORE_NAME)) {
store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
} else {
store = txn.objectStore(STORE_NAME);
}
if (!store.indexNames.contains('hash')) {
store.createIndex('hash', 'hash', { unique: false });
}
if (!store.indexNames.contains('templateId')) {
store.createIndex('templateId', 'templateId', { unique: false });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject('DB Error');
});
const HistoryManager = {
async add(blob, filename) {
const db = await dbPromise;
const hash = await computeFileHash(blob);
const templateId = await computeTemplateId(blob);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const templateIndex = store.index('templateId');
const req = templateIndex.get(templateId);
req.onsuccess = () => {
const existing = req.result;
if (existing) {
gpLog("Duplicate template detected (same image, possibly different position). Updating entry.");
store.delete(existing.id);
}
const item = {
blob: blob,
name: filename || `Image_${Date.now()}`,
date: Date.now(),
hash: hash,
templateId: templateId
};
store.add(item);
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async getAll() {
const db = await dbPromise;
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => resolve(req.result.reverse());
});
},
async delete(id) {
const db = await dbPromise;
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
});
},
async clear() {
const db = await dbPromise;
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => resolve();
});
}
};
// ========== IMPORT/EXPORT FUNCTIONS ==========
// Helper function to convert blob to base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
async function exportToZip() {
gpLog("exportToZip: Starting export...");
const images = await HistoryManager.getAll();
gpLog(`exportToZip: Retrieved ${images.length} images`);
if (images.length === 0) {
notifyUser("Info", "No images to export.");
return;
}
// JSZip doesn't work in Tampermonkey sandbox - use JSON bundle instead
gpLog("exportToZip: Using JSON bundle export (JSZip incompatible with this environment)");
try {
const exportData = {
version: "3.4",
exportDate: new Date().toISOString(),
images: []
};
for (let i = 0; i < images.length; i++) {
const imgData = images[i];
gpLog(`Encoding image ${i+1}/${images.length}: ${imgData.name}`);
const base64 = await blobToBase64(imgData.blob);
exportData.images.push({
id: imgData.id,
name: imgData.name,
date: imgData.date,
hash: imgData.hash,
templateId: imgData.templateId,
imageData: base64,
mimeType: imgData.blob.type || 'image/png'
});
}
gpLog(`exportToZip: Creating download...`);
const jsonStr = JSON.stringify(exportData);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `GeoPixels_History_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
gpLog("exportToZip: Export complete");
notifyUser("Success", `Exported ${images.length} images to JSON bundle.`);
} catch (error) {
console.error("exportToZip failed:", error);
gpLog(`exportToZip: ERROR - ${error.message}`);
notifyUser("Error", "Failed to export: " + error.message);
}
}
async function importFromZip(file) {
try {
gpLog(`importFromZip: Starting import of ${file.name}`);
// Check if it's a JSON file (new format)
if (file.name.endsWith('.json')) {
gpLog("importFromZip: Detected JSON bundle format");
const text = await file.text();
const data = JSON.parse(text);
if (!data.images || !Array.isArray(data.images)) {
notifyUser("Error", "Invalid JSON: 'images' array not found.");
return;
}
let imported = 0;
for (const imgEntry of data.images) {
// Convert base64 back to blob
const byteCharacters = atob(imgEntry.imageData);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: imgEntry.mimeType || 'image/png' });
// Check for duplicate
const existingImages = await HistoryManager.getAll();
const isDuplicate = existingImages.some(img => img.hash === imgEntry.hash);
if (!isDuplicate) {
await HistoryManager.add(blob, imgEntry.name, imgEntry.hash);
imported++;
gpLog(`Imported: ${imgEntry.name}`);
} else {
gpLog(`Skipped duplicate: ${imgEntry.name}`);
}
}
notifyUser("Success", `Imported ${imported} images from JSON bundle.`);
return;
}
// Try ZIP format (legacy) - may not work
gpLog("importFromZip: Attempting ZIP format (may fail)");
const zip = await JSZip.loadAsync(file);
const metadataFile = zip.file('metadata.json');
if (!metadataFile) {
notifyUser("Error", "Invalid ZIP: metadata.json not found.");
return;
}
const metadataText = await metadataFile.async('text');
const metadata = JSON.parse(metadataText);
let imported = 0;
for (const item of metadata) {
const imageFile = zip.file(item.filename);
if (imageFile) {
const blob = await imageFile.async('blob');
await HistoryManager.add(blob, item.name);
imported++;
}
}
notifyUser("Success", `Imported ${imported} images from ZIP.`);
return true;
} catch (e) {
console.error(e);
notifyUser("Error", "Failed to import file.");
return false;
}
}
// ========== ALGORITHM (ENCODE/DECODE) ==========
function encodeRobustPosition(originalCanvas, gridX, gridY) {
const width = originalCanvas.width;
const height = originalCanvas.height;
const newCanvas = document.createElement('canvas');
newCanvas.width = width;
newCanvas.height = height + 1;
const ctx = newCanvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(originalCanvas, 0, 1);
const headerImage = ctx.getImageData(0, 0, width, 1);
const data = headerImage.data;
const valX = (gridX + POSITION_OFFSET) >>> 0;
const valY = (gridY + POSITION_OFFSET) >>> 0;
const packetSize = 5;
const maxPackets = Math.floor(width / packetSize);
for (let i = 0; i < maxPackets; i++) {
const base = (i * packetSize) * 4;
data[base] = MARKER_R; data[base + 1] = MARKER_G; data[base + 2] = MARKER_B; data[base + 3] = 255;
data[base + 4] = (valX >>> 24) & 0xFF; data[base + 5] = (valX >>> 16) & 0xFF; data[base + 6] = 0; data[base + 7] = 255;
data[base + 8] = (valX >>> 8) & 0xFF; data[base + 9] = valX & 0xFF; data[base + 10] = 0; data[base + 11] = 255;
data[base + 12] = (valY >>> 24) & 0xFF; data[base + 13] = (valY >>> 16) & 0xFF; data[base + 14] = 0; data[base + 15] = 255;
data[base + 16] = (valY >>> 8) & 0xFF; data[base + 17] = valY & 0xFF; data[base + 18] = 0; data[base + 19] = 255;
}
ctx.putImageData(headerImage, 0, 0);
return newCanvas;
}
function decodeRobustPosition(img) {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const headerData = ctx.getImageData(0, 0, img.width, 1).data;
const votesX = new Map();
const votesY = new Map();
let validPackets = 0;
const packetSize = 5;
const maxPackets = Math.floor(img.width / packetSize);
for (let i = 0; i < maxPackets; i++) {
const base = (i * packetSize) * 4;
if (headerData[base] === MARKER_R && headerData[base + 1] === MARKER_G && headerData[base + 2] === MARKER_B && headerData[base + 3] === 255) {
const xVal = ((headerData[base + 4] << 24) | (headerData[base + 5] << 16) | (headerData[base + 8] << 8) | headerData[base + 9]) >>> 0;
const yVal = ((headerData[base + 12] << 24) | (headerData[base + 13] << 16) | (headerData[base + 16] << 8) | headerData[base + 17]) >>> 0;
votesX.set(xVal, (votesX.get(xVal) || 0) + 1);
votesY.set(yVal, (votesY.get(yVal) || 0) + 1);
validPackets++;
}
}
if (validPackets === 0) return null;
const getWinner = (map) => [...map.entries()].reduce((a, b) => b[1] > a[1] ? b : a)[0];
const gridX = getWinner(votesX) - POSITION_OFFSET;
const gridY = getWinner(votesY) - POSITION_OFFSET;
const cleanCanvas = document.createElement('canvas');
cleanCanvas.width = img.width;
cleanCanvas.height = img.height - 1;
const cleanCtx = cleanCanvas.getContext('2d');
cleanCtx.drawImage(canvas, 0, 1, img.width, img.height - 1, 0, 0, img.width, img.height - 1);
return { gridX, gridY, cleanCanvas };
}
// ========== PREVIEW FUNCTIONALITY ==========
let previewImageCache = null;
let previewRenderHandler = null;
function drawPreviewImageOnCanvas() {
gpLog("drawPreviewImageOnCanvas called");
if (!previewOverlay) {
gpLog("No preview overlay, returning");
return;
}
if (!previewActive) {
gpLog("Preview not active, returning");
return;
}
const savedImageData = localStorage.getItem('ghostImageData');
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
if (!savedCoordsStr || !savedImageData) {
gpLog("Missing ghost image data or coords in localStorage");
return;
}
const coords = JSON.parse(savedCoordsStr);
gpLog("Ghost coords", coords);
// Use cached image to avoid reloading
if (!previewImageCache || previewImageCache.src !== savedImageData) {
previewImageCache = new Image();
previewImageCache.src = savedImageData;
gpLog("Loading new preview image");
}
const img = previewImageCache;
if (!img.complete) {
gpLog("Image not loaded yet, waiting...");
img.onload = () => {
gpLog("Image loaded, redrawing");
drawPreviewImageOnCanvas();
};
return;
}
gpLog("Image loaded, dimensions:", { width: img.width, height: img.height });
// Get required game variables
const pixelCanvas = document.getElementById('pixel-canvas');
if (!pixelCanvas) {
gpLog("ERROR: pixel-canvas not found");
return;
}
// Match canvas size to pixel canvas
if (previewOverlay.width !== pixelCanvas.width || previewOverlay.height !== pixelCanvas.height) {
previewOverlay.width = pixelCanvas.width;
previewOverlay.height = pixelCanvas.height;
gpLog("Resized preview canvas to", { width: pixelCanvas.width, height: pixelCanvas.height });
}
const ctx = previewOverlay.getContext('2d');
const { width, height } = previewOverlay;
ctx.clearRect(0, 0, width, height);
gpLog("Cleared canvas");
// Get map and turf using safe helper to avoid DOM element conflicts
const map = getPageVariable('map');
const turf = getPageVariable('turf');
// gridSize is often 25 (standard grid size for geopixels)
// Try to get from page, fallback to defaults
let gridSize = getPageVariable('gridSize') || 25;
let halfSize = getPageVariable('halfSize') || (gridSize / 2);
let offsetMetersX = getPageVariable('offsetMetersX') || 0;
let offsetMetersY = getPageVariable('offsetMetersY') || 0;
gpLog("Grid values:", { gridSize, halfSize, offsetMetersX, offsetMetersY });
if (!map || !turf) {
gpLog("ERROR: Missing required variables", {
hasMap: !!map,
hasTurf: !!turf,
gridSize: gridSize
});
return;
}
if (typeof map.project !== 'function') {
gpLog("ERROR: map.project is not a function", { mapType: typeof map });
return;
}
// Calculate corners using the SAME method as the game's drawGhostImageOnCanvas
// Top-left pixel center
const tl_pixel_center_x = coords.gridX * gridSize;
const tl_pixel_center_y = coords.gridY * gridSize;
// Top-left mercator edge
const tl_merc_edge = [
tl_pixel_center_x - halfSize + offsetMetersX,
tl_pixel_center_y + halfSize + offsetMetersY
];
// Bottom-right grid coordinates
const br_pixel_gridX = coords.gridX + img.width - 1;
const br_pixel_gridY = coords.gridY - img.height + 1;
const br_pixel_center_x = br_pixel_gridX * gridSize;
const br_pixel_center_y = br_pixel_gridY * gridSize;
// Bottom-right mercator edge
const br_merc_edge = [
br_pixel_center_x + halfSize + offsetMetersX,
br_pixel_center_y - halfSize + offsetMetersY
];
gpLog("Mercator coords (ghost method)", { tl_merc_edge, br_merc_edge });
// Convert to WGS84 and then project to screen
const topLeftScreen = map.project(turf.toWgs84(tl_merc_edge));
const bottomRightScreen = map.project(turf.toWgs84(br_merc_edge));
gpLog("Screen coords", { topLeftScreen, bottomRightScreen });
const drawX = topLeftScreen.x;
const drawY = topLeftScreen.y;
const screenWidth = bottomRightScreen.x - drawX;
const screenHeight = bottomRightScreen.y - drawY;
gpLog("Draw position and dimensions", { drawX, drawY, screenWidth, screenHeight });
// Check if visible
if (drawX + screenWidth < 0 ||
drawX > width ||
drawY + screenHeight < 0 ||
drawY > height) {
gpLog("Image not in viewport, skipping draw");
return;
}
// Draw fully opaque
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, drawX, drawY, screenWidth, screenHeight);
gpLog("Drew preview image successfully");
}
function togglePreview(button) {
gpLog("togglePreview called, current state:", previewActive);
gpLog("Button click - environment check:", {
windowExists: typeof window !== 'undefined',
unsafeWindowExists: typeof unsafeWindow !== 'undefined',
windowKeys: typeof window !== 'undefined' ? Object.keys(window).filter(k => k.includes('map') || k.includes('turf')).slice(0, 10) : [],
unsafeWindowKeys: typeof unsafeWindow !== 'undefined' ? Object.keys(unsafeWindow).filter(k => k.includes('map') || k.includes('turf')).slice(0, 10) : []
});
if (previewActive) {
// Deactivate preview
gpLog("Deactivating preview");
if (previewOverlay && previewOverlay.parentNode) {
previewOverlay.parentNode.removeChild(previewOverlay);
gpLog("Removed preview overlay from DOM");
}
// Unhook from map events
if (previewRenderHandler) {
const map = getPageVariable('map');
if (map && typeof map.off === 'function') {
try {
map.off('move', previewRenderHandler);
map.off('zoom', previewRenderHandler);
map.off('rotate', previewRenderHandler);
gpLog("Removed map event listeners");
} catch (e) {
gpLog("Error removing map listeners", e);
}
}
}
previewOverlay = null;
previewImageCache = null;
previewRenderHandler = null;
previewActive = false;
button.innerHTML = '👁️ Preview';
button.classList.remove('active');
gpLog("Preview deactivated");
} else {
// Activate preview
gpLog("Activating preview");
const savedImageData = localStorage.getItem('ghostImageData');
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
if (!savedImageData || !savedCoordsStr) {
gpLog("ERROR: No ghost image data in localStorage");
notifyUser("Error", "No ghost image on map to preview.");
return;
}
gpLog("Found ghost data in localStorage");
// Find the pixel canvas to match its size
const pixelCanvas = document.getElementById('pixel-canvas');
if (!pixelCanvas) {
gpLog("ERROR: pixel-canvas not found");
notifyUser("Error", "Pixel canvas not found. Make sure you're on the map view.");
return;
}
gpLog("Found pixel canvas", { width: pixelCanvas.width, height: pixelCanvas.height });
// Verify map exists
const map = getPageVariable('map');
if (!map) {
gpLog("ERROR: map not found in any scope");
notifyUser("Error", "Map not initialized yet. Please wait a moment and try again.");
return;
}
gpLog("Map object found", {
mapType: typeof map,
hasProject: typeof map.project,
isHTMLElement: map instanceof HTMLElement,
constructor: map.constructor ? map.constructor.name : 'unknown'
});
if (typeof map.project !== 'function') {
gpLog("ERROR: map.project is not a function", {
mapType: typeof map,
projectType: typeof map.project,
mapKeys: Object.keys(map).slice(0, 20),
mapConstructor: map.constructor ? map.constructor.name : 'unknown'
});
notifyUser("Error", "Map projection not available. Page may not be fully loaded.");
return;
}
gpLog("map.project verified as function");
// Verify turf exists
const turf = getPageVariable('turf');
if (!turf) {
gpLog("ERROR: turf not found in any scope");
notifyUser("Error", "Turf.js library not loaded. Page may not be fully loaded.");
return;
}
gpLog("Turf object found", { turfType: typeof turf, hasToWgs84: typeof turf.toWgs84 });
if (typeof turf.toWgs84 !== 'function') {
gpLog("ERROR: turf.toWgs84 is not a function", {
turfType: typeof turf,
toWgs84Type: typeof turf.toWgs84,
turfKeys: Object.keys(turf).slice(0, 20)
});
notifyUser("Error", "Map projection not available. Page may not be fully loaded.");
return;
}
gpLog("turf.toWgs84 verified as function");
gpLog("Map and turf are ready with required functions");
// Create preview canvas
previewOverlay = document.createElement('canvas');
previewOverlay.id = 'gp-preview-canvas';
previewOverlay.className = 'pixel-perfect';
previewOverlay.width = pixelCanvas.width;
previewOverlay.height = pixelCanvas.height;
previewOverlay.style.cssText = 'display: block; image-rendering: pixelated; position: absolute; top: 0; left: 0; pointer-events: none; z-index: 5;';
gpLog("Created preview canvas element");
// Insert into DOM - find the map container
const mapContainer = map.getContainer ? map.getContainer() : document.getElementById('map');
if (mapContainer) {
mapContainer.appendChild(previewOverlay);
gpLog("Appended preview canvas to map container");
} else {
document.body.appendChild(previewOverlay);
gpLog("Appended preview canvas to body (fallback)");
}
previewActive = true;
button.innerHTML = '👁️ Hide Preview';
button.classList.add('active');
// Create render handler
previewRenderHandler = () => {
gpLog("Map event triggered, redrawing preview");
drawPreviewImageOnCanvas();
};
// Hook into map events (same as geopixels++)
try {
map.on('move', previewRenderHandler);
map.on('zoom', previewRenderHandler);
map.on('rotate', previewRenderHandler);
gpLog("Attached to map events");
} catch (e) {
gpLog("ERROR attaching map listeners", e);
}
// Render once immediately
gpLog("Drawing initial preview");
drawPreviewImageOnCanvas();
gpLog("Preview activated successfully");
}
}
/**
* Replicates the logic of the 'Save Pos' button to cache the currently placed ghost image.
* This function is available globally but is no longer called automatically.
*/
async function cacheCurrentGhostPosition() {
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
const savedImageData = localStorage.getItem('ghostImageData');
if (!savedCoordsStr || !savedImageData) {
gpLog("Auto-Cache: No ghost image on map or coordinates found.");
return;
}
gpLog("Auto-Cache: Starting cache process.");
const coords = JSON.parse(savedCoordsStr);
const img = new Image();
img.src = savedImageData;
await new Promise(r => img.onload = r);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.height = img.height;
tempCanvas.getContext('2d').drawImage(img, 0, 0);
const encodedCanvas = encodeRobustPosition(tempCanvas, coords.gridX, coords.gridY);
encodedCanvas.toBlob(async (blob) => {
if(!blob) return;
// Save to History (Cache)
try {
await HistoryManager.add(blob, `Backup_${coords.gridX}_${coords.gridY}`);
gpLog("Auto-Cache: Cached image with position data.");
notifyUser("Auto-Cache", `Ghost image position ${coords.gridX}, ${coords.gridY} auto-cached.`);
} catch (e) {
console.error("Auto-Cache failed", e);
notifyUser("Auto-Cache Error", "Failed to auto-cache the image position.");
}
}, 'image/png');
}
// Expose for direct use if needed, but primarily used internally now
window.cacheCurrentGhostPosition = cacheCurrentGhostPosition;
// ========== GAME INTEGRATION ==========
function applyCoordinatesToGame(coords) {
gpLog("Applying coordinates...", coords);
let attempts = 0;
const interval = setInterval(() => {
const placeBtn = document.getElementById('initiatePlaceGhostBtn');
if (placeBtn && !placeBtn.disabled) {
clearInterval(interval);
localStorage.setItem('ghostImageCoords', JSON.stringify(coords));
// Get function using safe helper
const initializeGhostFromStorage = getPageVariable('initializeGhostFromStorage');
if (typeof initializeGhostFromStorage === 'function') {
gpLog("Calling initializeGhostFromStorage to place template");
initializeGhostFromStorage();
notifyUser("Auto-Place", `Position detected: ${coords.gridX}, ${coords.gridY}`);
} else {
gpLog("ERROR: initializeGhostFromStorage function not found");
notifyUser("Warning", `Position set to ${coords.gridX}, ${coords.gridY} but auto-place failed. Click 'Place on Map' manually.`);
}
}
if (++attempts > 50) {
clearInterval(interval);
gpLog("Timeout waiting for place button to be ready");
}
}, 100);
}
async function loadImageToCanvas(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
}
// ========== PROCESSING LOGIC ==========
async function processAndLoadImage(file, saveToHistory = true) {
gpLog("Processing image...");
const placeBtn = document.getElementById('initiatePlaceGhostBtn');
if (placeBtn) { placeBtn.innerText = "Analyzing..."; placeBtn.disabled = true; }
try {
const img = await loadImageToCanvas(file);
const decoded = decodeRobustPosition(img);
let finalFile = file;
let coords = null;
if (decoded) {
gpLog("Found encoded position.", { gridX: decoded.gridX, gridY: decoded.gridY });
coords = { gridX: decoded.gridX, gridY: decoded.gridY };
const cleanBlob = await new Promise(r => decoded.cleanCanvas.toBlob(r, 'image/png'));
finalFile = new File([cleanBlob], file.name || "ghost.png", { type: "image/png" });
} else {
gpLog("No encoded position found in image");
}
if (saveToHistory) {
await HistoryManager.add(file, file.name);
}
const input = document.getElementById('ghostImageInput');
const dt = new DataTransfer();
dt.items.add(finalFile);
input.files = dt.files;
isInternalUpdate = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
isInternalUpdate = false;
// Wait for the game to process the image first
await new Promise(resolve => setTimeout(resolve, 100));
if (coords) {
gpLog("Applying coordinates to game", coords);
applyCoordinatesToGame(coords);
} else {
// Clear old coordinates if this template has no encoded position
localStorage.removeItem('ghostImageCoords');
gpLog("No encoded position found, cleared old coordinates");
}
} catch (e) {
console.error(e);
notifyUser("Error", "Failed to process image.");
} finally {
if (placeBtn) placeBtn.innerText = "Place on Map";
}
}
// ========== INTERCEPTOR ==========
function setupNativeInterceptor() {
const input = document.getElementById('ghostImageInput');
if (!input) return;
// 3. Add .zip to the file input's accepted types
input.setAttribute('accept', 'image/png, image/jpeg, image/webp, image/gif, application/zip, .zip');
input.addEventListener('change', async (e) => {
if (isInternalUpdate) return;
const file = e.target.files[0];
if (!file) return;
e.stopImmediatePropagation();
e.preventDefault();
// Check if it's a ZIP file
if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
gpLog("Detected ZIP file upload");
const success = await importFromZip(file);
if (success) {
// Clear the input so same file can be uploaded again
input.value = '';
}
return;
}
// Otherwise process as image
processAndLoadImage(file, false);
}, true);
}
// ========== UI HANDLERS ==========
async function handleUrlUpload() {
const url = prompt("Enter Image or ZIP URL:");
if (!url) return;
try {
// Use GM_xmlhttpRequest to bypass CSP restrictions
const blob = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response.response);
} else {
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
}
},
onerror: (error) => {
reject(new Error('Failed to fetch URL'));
},
ontimeout: () => {
reject(new Error('Request timed out'));
}
});
});
// Check if it's a ZIP file
if (blob.type === 'application/zip' || blob.type === 'application/x-zip-compressed' || url.toLowerCase().endsWith('.zip')) {
gpLog("Detected ZIP file from URL");
await importFromZip(blob);
notifyUser("Success", "Imported cache from URL!");
return;
}
// Otherwise treat as image
if (!blob.type.startsWith('image/')) throw new Error("Invalid image");
processAndLoadImage(new File([blob], "url_upload.png", { type: blob.type }), false);
} catch (e) {
console.error(e);
notifyUser("Error", "Could not load file from URL: " + e.message);
}
}
async function downloadWithPos() {
const savedImageData = localStorage.getItem('ghostImageData');
if (!savedImageData) {
notifyUser("Error", "No ghost image loaded.");
return;
}
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
const img = new Image();
img.src = savedImageData;
await new Promise(r => img.onload = r);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.height = img.height;
tempCanvas.getContext('2d').drawImage(img, 0, 0);
if (savedCoordsStr) {
// If coordinates exist, encode them and save
const coords = JSON.parse(savedCoordsStr);
const encodedCanvas = encodeRobustPosition(tempCanvas, coords.gridX, coords.gridY);
encodedCanvas.toBlob(async (blob) => {
if(!blob) return;
// Save to History (Cache)
try {
await HistoryManager.add(blob, `Backup_${coords.gridX}_${coords.gridY}`);
gpLog("Cached image with position data");
notifyUser("Success", "Template saved to history!");
} catch (e) {
console.error("Cache failed", e);
notifyUser("Error", "Failed to save template");
}
}, 'image/png');
} else {
// No coordinates: just save the image as-is
tempCanvas.toBlob(async (blob) => {
if(!blob) return;
try {
await HistoryManager.add(blob, `Image_${Date.now()}`);
gpLog("Cached image without position data");
notifyUser("Success", "Template saved to history!");
} catch (e) {
console.error("Cache failed", e);
notifyUser("Error", "Failed to save template");
}
}, 'image/png');
}
}
async function openHistoryModal() {
const existing = document.getElementById('gp-history-modal');
if (existing) existing.remove();
const images = await HistoryManager.getAll();
const modal = document.createElement('div');
modal.id = 'gp-history-modal';
modal.className = 'gp-to-modal-overlay';
modal.innerHTML = `
<div class="gp-to-modal-panel">
<div class="gp-to-header">
<span class="gp-to-title">Image History (${images.length})</span>
<div class="flex gap-2">
<button id="gp-export-zip" class="gp-to-btn gp-to-btn-orange text-xs">💾 Export JSON</button>
<button id="gp-import-zip" class="gp-to-btn gp-to-btn-green text-xs">📁 Import JSON</button>
<button id="gp-clear-all" class="gp-to-btn gp-to-btn-red text-xs">Clear All</button>
<button id="gp-close-hist" class="gp-to-btn gp-to-btn-gray">Close</button>
</div>
</div>
<div class="gp-to-grid" id="gp-history-grid">
${images.length === 0 ? '<p class="p-4 text-gray-500 col-span-full text-center">No images found.</p>' : ''}
</div>
</div>
`;
document.body.appendChild(modal);
const grid = modal.querySelector('#gp-history-grid');
images.forEach(imgData => {
const card = document.createElement('div');
card.className = 'gp-to-card';
card.innerHTML = `
<button class="gp-to-delete-btn" title="Delete">✖</button>
<img src="${URL.createObjectURL(imgData.blob)}" />
<div class="gp-to-card-footer">${new Date(imgData.date).toLocaleTimeString()} - ${imgData.name.substring(0,12)}</div>
`;
card.onclick = (e) => {
if (e.target.closest('.gp-to-delete-btn')) return;
processAndLoadImage(imgData.blob, false);
modal.remove();
};
card.querySelector('.gp-to-delete-btn').onclick = async () => {
await HistoryManager.delete(imgData.id);
card.remove();
};
grid.appendChild(card);
});
modal.querySelector('#gp-export-zip').onclick = async () => {
await exportToZip();
};
modal.querySelector('#gp-import-zip').onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json, .zip, application/json, application/zip'; // Accept JSON (new) and ZIP (legacy)
input.onchange = async (e) => {
const file = e.target.files[0];
if (file) {
await importFromZip(file);
modal.remove();
openHistoryModal(); // Refresh the modal
}
};
input.click();
};
modal.querySelector('#gp-clear-all').onclick = async () => {
if(confirm("Clear all cached images?")) {
await HistoryManager.clear();
modal.remove();
}
};
modal.querySelector('#gp-close-hist').onclick = () => modal.remove();
}
// ========== INJECTION ==========
/**
* Watches the document for the coordinate-setting success message
* and triggers the auto-cache function.
* This addresses issue #2.
*/
function setupAlertBodyObserver() {
const targetNode = document.getElementById('alertBody');
if (!targetNode) {
gpLog("Could not find alertBody for position observer.");
return;
}
const observer = new MutationObserver((mutationsList, observer) => {
for(const mutation of mutationsList) {
if (mutation.type === 'childList' || mutation.type === 'characterData') {
const textContent = targetNode.textContent;
if (textContent && textContent.includes("Ghost image position set")) {
gpLog("Detected 'Ghost image position set'. Triggering auto-cache.");
cacheCurrentGhostPosition();
// Disconnect after first success to avoid spamming the cache,
// as a new observer will be created when the modal is opened next.
observer.disconnect();
break;
}
}
}
});
// Start observing the target node for configured mutations
const config = { childList: true, subtree: true, characterData: true };
observer.observe(targetNode, config);
}
function injectControls() {
const modal = document.getElementById('ghostImageModal');
if (!modal) return;
const container = modal.querySelector('.flex.flex-wrap.items-center.justify-center.gap-3');
if (!container || container.dataset.gpInjected) return;
container.dataset.gpInjected = "true";
// 1. Remove the 'hidden' class from the hexDisplay span
const hexDisplay = document.getElementById('hexDisplay');
if (hexDisplay) {
hexDisplay.classList.remove('hidden');
gpLog("Removed 'hidden' class from hexDisplay.");
}
setupNativeInterceptor();
const btnUrl = document.createElement('button');
btnUrl.innerHTML = '🔗 URL'; btnUrl.className = 'gp-to-btn gp-to-btn-blue shadow';
btnUrl.title = 'Load from URL (Image or ZIP)';
btnUrl.onclick = handleUrlUpload;
const btnLocal = document.createElement('button');
btnLocal.innerHTML = '📂 File'; btnLocal.className = 'gp-to-btn gp-to-btn-green shadow';
btnLocal.title = 'Upload Image or ZIP';
// Note: The click handler for this just triggers the native input, which we intercept.
btnLocal.onclick = () => document.getElementById('ghostImageInput').click();
const btnHist = document.createElement('button');
btnHist.innerHTML = '📜 History'; btnHist.className = 'gp-to-btn gp-to-btn-purple shadow';
btnHist.onclick = openHistoryModal;
const btnDL = document.createElement('button');
btnDL.innerHTML = '💾 Save'; btnDL.className = 'gp-to-btn gp-to-btn-gray shadow';
btnDL.onclick = downloadWithPos;
const btnPreview = document.createElement('button');
btnPreview.innerHTML = '👁️ Preview';
btnPreview.className = 'gp-to-btn gp-to-btn-cyan shadow';
btnPreview.title = 'Toggle image preview overlay';
btnPreview.onclick = () => togglePreview(btnPreview);
const btnGoTo = document.createElement('button');
btnGoTo.innerHTML = '🎯 Go To';
btnGoTo.className = 'gp-to-btn gp-to-btn-orange shadow';
btnGoTo.title = 'Teleport to template location';
btnGoTo.onclick = goToTemplateLocation;
container.prepend(btnGoTo);
container.prepend(btnPreview);
container.prepend(btnDL);
container.prepend(btnHist);
container.prepend(btnLocal);
container.prepend(btnUrl);
// Auto-caching disabled - user must manually press Save Pos button
// setupAlertBodyObserver();
}
const observer = new MutationObserver(() => injectControls());
observer.observe(document.body, { childList: true, subtree: true });
document.querySelector('label[for="ghostImageInput"]')?.classList.add('hidden');
gpLog("GeoPixels Ultimate Ghost Template Manager v3.4 Loaded (with uint8array ZIP fix)");
})();