GeoPixels++

QOL features for https://geopixels.net/ with color palette management

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GeoPixels++
// @description  QOL features for https://geopixels.net/ with color palette management
// @author       Thinkbot
// @contributor  Observable
// @contributor  Phlegethonia
// @contributor  h65e3j
// @version      0.7.0
// @match        https://*.geopixels.net/*
// @namespace    https://github.com/thin-kbot
// @homepage     https://github.com/thin-kbot/geopixels-plusplus
// @icon         https://raw.githubusercontent.com/thin-kbot/geopixels-plusplus/refs/heads/main/img/icon.png
// @license      GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt
// @supportURL   https://ko-fi.com/thinkbot
// @grant        unsafeWindow
// ==/UserScript==

(function () {
	//#region Global variables
	const usw = unsafeWindow;

	const LOG_LEVELS = {
		error: { label: "ERR", color: "red" },
		info: { label: "INF", color: "lime" },
		warn: { label: "WRN", color: "yellow" },
		debug: { label: "DBG", color: "cyan" },
	};

	const STORAGE_KEYS = {
		censor: "geo++_censorRects",
		keybinds: "geo++_keybinds",
		settings: "geo++_settings",
	};

	const SOUNDS = [
		{
			name: "Pixel placement",
			variable: "soundBufferPop",
		},
		{
			name: '"Paint"',
			variable: "soundBufferThump",
		},
		{
			name: "Max charges reached",
			variable: "soundBufferMaxCharges",
		},
	];
	let soundToChangeIdx = 0;

	let censorRects;
	let censorMode = false;
	let isDraggingCensor = false;
	let censorStartPoint = null;
	let tempCensorRect = null;
	let oldCensorRect = null;
	let censorCanvas;

	const KEY_BINDINGS = {
		toggleGhost: {
			text: "Toggle ghost image",
			defaultKey: "y",
			keydown: () => document.getElementById("ghost-canvas").toggleAttribute("hidden"),
		},
		placeGhost: {
			text: "Set ghost image's top left",
			defaultKey: "e",
			keydown: () => {
				const pos = screenPointToGrid(document.getElementById("pixel-canvas"), mouseX, mouseY);
				ghostImageTopLeft = pos;
				localStorage.setItem("ghostImageCoords", JSON.stringify(pos));
				log(LOG_LEVELS.debug, "Ghost image position set.");
				drawGhostImageOnCanvas();
			},
		},
		toggleCensorMode: {
			text: "Toggle Censor Mode",
			defaultKey: "m",
			keydown: () => toggleCensorMode(),
		},
		addCensorRect: {
			text: "Add one Censor Rect at Mouse",
			defaultKey: "l",
			keydown: (e) => {
				if (e.repeat) return;
				if (!censorMode) enableCensorMode();
				startCensorDraw();
			},
			keyup: (e) => {
				if (e.repeat || !isDraggingCensor) return;
				endCensorDraw();
				if (censorMode) disableCensorMode();
			},
		},
	};
	let mouseX, mouseY;

	const THEMES = {
		system: {
			name: "System default",
			style: () =>
				window.matchMedia("(prefers-color-scheme: dark)").matches ? THEMES.simple_black.style : "",
		},
		default: {
			name: "GeoPixels++ Default",
			style: "",
		},
		simple_black: {
			name: "Simple Black",
			style:
				":root{color-scheme:dark;--color-red-900:#ffe2e2;--color-red-800:#ffc9c9;--color-red-700:#ffa2a2;--color-red-600:#ff6467;--color-red-500:#fb2c36;--color-red-400:#e7000b;--color-red-300:#c10007;--color-red-200:#9f0712;--color-red-100:#82181a;--color-red-50:#460809;--color-orange-800:#ffd6a7;--color-orange-700:#ffb869;--color-orange-600:#ff8903;--color-orange-500:#ff6900;--color-orange-400:#f54900;--color-orange-200:#9f2d00;--color-orange-100:#7e2a0c;--color-orange-50:#441306;--color-yellow-900:#fef9c2;--color-yellow-800:#fff085;--color-yellow-700:#ffdf20;--color-yellow-600:#fdc700;--color-yellow-500:#f0b100;--color-yellow-400:#d08700;--color-yellow-300:#a65f00;--color-yellow-200:#894b00;--color-yellow-100:#733e0a;--color-yellow-50:#432004;--color-green-900:#dcfce7;--color-green-700:#7bf1a7;--color-green-600:#06df72;--color-green-500:#00c950;--color-green-200:#026630;--color-green-100:#0d542b;--color-green-50:#032e15;--color-sky-800:#b8e6fe;--color-sky-500:#00a6f4;--color-sky-200:#00598a;--color-sky-50:#052f4a;--color-blue-900:#dbeafe;--color-blue-800:#bedbff;--color-blue-700:#8ec5ff;--color-blue-600:#50a2ff;--color-blue-500:#2b7fff;--color-blue-400:#155dfb;--color-blue-300:#1447e6;--color-blue-200:#193cb8;--color-blue-100:#1c398e;--color-blue-50:#162556;--color-indigo-700:#a3b3ff;--color-indigo-600:#7c86ff;--color-indigo-500:#615fff;--color-purple-600:#c27aff;--color-purple-500:#ad46ff;--color-slate-700:#cad5e2;--color-slate-500:#62748e;--color-gray-900:#f3f4f6;--color-gray-800:#e5e7eb;--color-gray-700:#d1d5db;--color-gray-600:#99a1af;--color-gray-500:#6a7282;--color-gray-400:#4a5565;--color-gray-300:#364153;--color-gray-200:#1e2939;--color-gray-100:#101828;--color-gray-50:#030712;--color-white:#000;color:#fff}select{background:#000}.keybind-input{color:#d1d5db;background-color:#1f2937;border-color:#4b5563}#hoverProfileImg,#hoverBannerImg{background:#fff}#visualIdentitySection svg,#visualIdentitySection span{color:#fff;}",
		},
	};

	const SETTINGS_CATEGORIES = {
		appearance: "Appearance",
		censor: "Censor",
		keybinds: "Keybindings",
	};

	const SETTINGS = {
		theme: {
			name: "UI Theme",
			element: "select",
			category: SETTINGS_CATEGORIES.appearance,
			options: Object.entries(THEMES).map(([id, { name }]) => ({ value: id, text: name })),
			default: "system",
			apply: (val) => {
				let themeStyle = document.getElementById("gpp-theme-style");
				if (!themeStyle) {
					themeStyle = document.createElement("style");
					themeStyle.id = "gpp-theme-style";
					document.head.appendChild(themeStyle);
				}
				themeStyle.innerHTML =
					typeof THEMES[val]?.style === "function" ? THEMES[val].style() : THEMES[val]?.style ?? "";
			},
			attributes: {
				onchange: (e) => {
					SETTINGS.theme.apply(e.target.value);
					saveSetting("theme", e.target.value);
				},
			},
		},
		censorColor: {
			name: "Censor rects color",
			element: "input",
			category: SETTINGS_CATEGORIES.censor,
			default: "#000",
			attributes: {
				type: "color",
				onchange: (e) => {
					saveSetting("censorColor", e.target.value);
					drawCensorRects();
				},
				oninput: (e) => {
					settings.censorColor = e.target.value;
					drawCensorRects();
				},
			},
		},
		censorAlpha: {
			name: "Censor rects alpha",
			element: "input",
			category: SETTINGS_CATEGORIES.censor,
			default: 1,
			attributes: {
				type: "range",
				min: 0,
				max: 1,
				step: 0.01,
				onchange: (e) => {
					saveSetting("censorAlpha", e.target.value);
					drawCensorRects();
				},
				oninput: (e) => {
					settings.censorAlpha = e.target.value;
					drawCensorRects();
				},
			},
		},
		doInteractCensor: {
			name: "Interact with censors out of censor mode",
			element: "input",
			category: SETTINGS_CATEGORIES.censor,
			default: true,
			attributes: {
				type: "checkbox",
				onchange: (e) => saveSetting("doInteractCensor", e.target.checked),
			},
		},
		censorTextarea: {
			name: "Censor config",
			element: "textarea",
			category: SETTINGS_CATEGORIES.censor,
			value: () => JSON.stringify(getCensorRects()),
			init: (elm) =>
				document.addEventListener(
					"censorRectsChanged",
					() => (elm.value = JSON.stringify(getCensorRects()))
				),
			reset: (elm) => {
				elm.value = SETTINGS.censorTextarea.value();
				elm.dispatchEvent(new Event("input"));
			},
			attributes: {
				onchange: (e) => {
					let config;
					try {
						config = JSON.parse(e.target.value);
					} catch (err) {
						return alert(`Invalid json: ${err.message}`);
					}

					if (!isValidCensorConfig(config)) return alert("Invalid censor config");

					saveCensorRects(config);
					validateInput(e.target);
					log(LOG_LEVELS.info, "replaced Censor rects with", config);
				},
				oninput: (e) => {
					if (!isJsonString(e.target.value) || !isValidCensorConfig(JSON.parse(e.target.value)))
						e.target.style.borderColor = "red";
					else e.target.style.borderColor = "";
				},
			},
		},
		openCensorConfig: {
			name: "Open Censors Config",
			element: "button",
			category: SETTINGS_CATEGORIES.censor,
			attributes: { onclick: createCensorsConfigModal },
		},
		...Object.fromEntries(
			Object.entries(KEY_BINDINGS).map(([id, binding]) => [
				`keybind_${id}`,
				{
					name: `${binding.text}`,
					element: "input",
					category: SETTINGS_CATEGORIES.keybinds,
					value: () => keybinds[id] || "",
					...(binding.defaultKey && {
						reset: (elm) => {
							elm.value = binding.defaultKey;
							elm.dispatchEvent(new Event("change"));
						},
					}),
					attributes: {
						type: "keybind",
						onclick: (e) => e.target.select(),
						onchange: (e) => {
							saveKeybind(id, e.target.value.trim().toLowerCase());
							validateInput(e.target);
						},
					},
				},
			])
		),
	};
	//#endregion Global variables

	//#region Utils
	function log(lvl, ...args) {
		console.log(
			`%c[GeoPixels++] %c[${lvl.label}]`,
			"color: mediumvioletred;",
			`color:${lvl.color};`,
			...args
		);
	}

	function isJsonString(str) {
		try {
			JSON.parse(str);
		} catch {
			return false;
		}
		return true;
	}

	function screenPointToGrid(canvas, mX, mY) {
		const rect = canvas.getBoundingClientRect();

		// Convert screen coordinates to map coordinates
		const lngLat = map.unproject([mX - rect.left, mY - rect.top]);
		const merc = turf.toMercator([lngLat.lng, lngLat.lat]);

		return {
			gridX: Math.round(merc[0] / gridSize),
			gridY: Math.round(merc[1] / gridSize),
		};
	}

	function validateInput(elm) {
		elm.style.borderColor = "green";
		const td = parseFloat(getComputedStyle(elm).transitionDuration) * 2;
		setTimeout(() => (elm.style.borderColor = ""), (td ?? 0.5) * 1000);
	}

	function getAllCoordsBetween(a, b, size = 1) {
		const coords = [];
		for (let x = Math.min(a.x, b.x); x <= Math.max(a.x, b.x); x += size)
			for (let y = Math.min(a.y, b.y); y <= Math.max(a.y, b.y); y += size) coords.push({ x, y });
		return coords;
	}

	const snapToTile = (value) => Math.floor(value / SYNC_TILE_SIZE) * SYNC_TILE_SIZE;
	//#endregion Utils

	//#region Color Utils
	const cToHex = (c) => (+c).toString(16).padStart(2, "0");
	const hexToC = (h) => parseInt(h, 16);
	function toFullHex(hex) {
		hex = hex.toLowerCase();
		if (hex.length === 4 || hex.length === 5)
			hex = "#" + [...hex.slice(1)].map((c) => c + c).join("");
		if (hex.length === 7) hex += "ff";
		return hex;
	}
	const toOutputHex = (hex) => toFullHex(hex).slice(0, 7);
	const rgbaToHex = (r, g, b, a = 255) => "#" + cToHex(r) + cToHex(g) + cToHex(b) + cToHex(a);
	const rgbToRgbaString = ({ r, g, b }) => `rgba(${r},${g},${b},1)`;
	function hexToRgba(hex) {
		const h = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
		return { r: hexToC(h[1]), g: hexToC(h[2]), b: hexToC(h[3]), a: h[4] ? hexToC(h[4]) : 255 };
	}
	function parseColor(colorStr) {
		const h = /^#?([a-f\d]{6}(?:[a-f\d]{2})?|[a-f\d]{3,4})/i.exec(colorStr);
		if (h) return toFullHex(`#${h[1]}`);
		log(LOG_LEVELS.error, "Invalid color format:", colorStr);
		return null;
	}
	Array.prototype.toOutputString = function () {
		return this.map((c) => toOutputHex(c)).join("\n");
	};
	function colorsStringToHexArray(colorsString) {
		return colorsString
			.trim()
			.split("\n")
			.filter(Boolean)
			.map((c) => parseColor(c))
			.filter(Boolean);
	}
	//#endregion Color Utils

	//#region User Palette Functions
	const getUserPalette = () => Colors.slice(0, -1).map(toFullHex);

	function isInUserPalette(hex) {
		const is = getUserPalette().includes(hex);
		if (!is) log(LOG_LEVELS.warn, "Color not in user palette:", toOutputHex(hex));
		return is;
	}

	function setEnabledUserPalette(hexArray) {
		const colors = getUserPalette();
		activeColors = hexArray.map((h) => colors.indexOf(h)).filter((i) => i !== -1);
		activeColors.push(Colors.length - 1);

		localStorage.setItem("activeColors", JSON.stringify(activeColors));

		SetColorsProfile();
		SetColors();
		if (activeColors.length > 0 && (!pixelColor || !colors.includes(toFullHex(pixelColor))))
			changeColor(Colors[activeColors[0]]);

		log(LOG_LEVELS.info, "Enabled palette updated with", activeColors.length, "colors");
	}
	//#endregion User Palette Functions

	//#region Ghost Image Palette Functions
	function getGhostImageHexColors() {
		return ghostPaletteColors.map(({ hex }) => toFullHex(hex));
	}

	function getAvailableGhostColors() {
		const colors = getUserPalette();
		return getGhostImageHexColors().filter((c) => colors.includes(c));
	}

	function getEnabledGhostPalette() {
		if (ghostActivePaletteColors && ghostActivePaletteColors.size > 0)
			return Array.from(ghostActivePaletteColors).map((rgba) => rgbaToHex(...rgba.match(/\d+/g)));

		log(LOG_LEVELS.info, "No ghost colors enabled");
		return "";
	}

	function isInGhostPalette(hex) {
		const is = getGhostImageHexColors().includes(hex);
		if (!is) log(LOG_LEVELS.warn, "Color not in ghost palette:", hex);
		return is;
	}

	function setEnabledGhostPalette(hexArray) {
		if (!getGhostImageHexColors() || getGhostImageHexColors().length === 0)
			return log(LOG_LEVELS.warn, "No ghost image loaded");

		ghostActivePaletteColors = new Set(hexArray.map((h) => rgbToRgbaString(hexToRgba(h))));
		populateColorPaletteUI();
		regenerateGhostCanvas();
		drawGhostImageOnCanvas();

		log(LOG_LEVELS.info, "Ghost palette updated with", hexArray.length, "enabled colors");
	}
	//#endregion Ghost Image Palette Functions

	//#region Navigation
	function gotoCoords({ x, y }, zoom = 16) {
		const gSize = usw.gridSize || gridSize || 25;

		if (isNaN(x) || isNaN(y)) {
			log(LOG_LEVELS.error, "Invalid coordinates");
			return;
		}

		const mercX = x * gSize;
		const mercY = y * gSize;
		const lngLat = turf.toWgs84([mercX, mercY]);

		map.setCenter([lngLat[0], lngLat[1]]);
		map.setZoom(zoom);

		log(LOG_LEVELS.debug, `Moved to (${x}, ${y})`);
	}

	function gotoFromInput(input = "") {
		let coordString;

		if (!input.length) {
			const urlParams = new URLSearchParams(window.location.search);
			coordString = urlParams.get("coords") || urlParams.get("key");
			if (!coordString) {
				log(LOG_LEVELS.error, "No coordinates provided and none found in URL");
				return;
			}
			log(LOG_LEVELS.debug, `Using coords from page URL: ${coordString}`);
		} else if (input.includes("http") || input.includes("?coords=") || input.includes("?key=")) {
			try {
				const url = new URL(input.includes("http") ? input : "https://www.geopixels.net/" + input);
				coordString = url.searchParams.get("coords") || url.searchParams.get("key");
				if (!coordString) {
					log(LOG_LEVELS.error, "No coords parameter found in URL");
					return;
				}
				log(LOG_LEVELS.debug, `Extracted coords from URL: ${coordString}`);
			} catch (e) {
				log(LOG_LEVELS.log, "Invalid URL format");
				return;
			}
		} else {
			coordString = input;
		}

		const parts = coordString.trim().split(",");
		if (parts.length !== 2) {
			log(LOG_LEVELS.error, 'Invalid format. Use: "gridX,gridY"');
			return;
		}

		gotoCoords({ x: +parts[0], y: +parts[1] });
	}
	//#endregion Navigation

	//#region Sound
	function bto64(blob) {
		return new Promise((resolve) => {
			const reader = new FileReader();
			reader.onloadend = () => resolve(reader.result);
			reader.readAsDataURL(blob);
		});
	}

	function setBlobAsSound(s, blob) {
		blob
			.arrayBuffer()
			.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
			.then((decodedAudio) => {
				eval(`${s.variable} = decodedAudio`);
			})
			.catch((err) => (LOG_LEVELS.error, "Error loading audio:", err));
	}

	function saveBlobSound(s, blob) {
		setBlobAsSound(s, blob);
		bto64(blob).then((b64s) => localStorage.setItem(`geo++_${s.variable}`, b64s));
	}

	function loadBlobSounds() {
		for (const s of SOUNDS) {
			let storageItem = localStorage.getItem(`geo++_${s.variable}`);
			if (storageItem) {
				fetch(storageItem)
					.then((res) => res.blob())
					.then((blob) => setBlobAsSound(s, blob));
			}
		}
	}
	loadBlobSounds();

	function waitForSounds(callback) {
		let tries = 0;
		function check() {
			if (SOUNDS.every((s) => eval(`${s.variable} !== null`))) callback();
			else if (tries++ < 100) setTimeout(check, 100);
		}
		check();
	}

	waitForSounds(loadBlobSounds);
	//#endregion Sound

	//#region Censor
	function isValidCensorConfig(json) {
		return (
			Array.isArray(json) &&
			json.every(
				(obj) =>
					typeof obj === "object" &&
					obj !== null &&
					["gridX", "gridY", "width", "height"].every((key) => Number.isInteger(obj[key]))
			)
		);
	}

	censorCanvas = document.createElement("canvas");
	censorCanvas.id = "censor-canvas";
	censorCanvas.className = "inset-0 absolute pointer-events-none";
	document.body.appendChild(censorCanvas);

	//#region Drawing censor
	function enableCensorMode() {
		censorMode = true;
		censorCanvas.style.pointerEvents = "auto";
		censorCanvas.style.cursor = "crosshair";

		log(LOG_LEVELS.debug, "Censor mode enabled");
	}

	function disableCensorMode() {
		censorMode = false;
		isDraggingCensor = false;
		censorStartPoint = null;
		tempCensorRect = null;
		censorCanvas.style.pointerEvents = "none";
		log(LOG_LEVELS.debug, "Censor mode disabled");
	}

	function toggleCensorMode() {
		if (censorMode) disableCensorMode();
		else enableCensorMode();
	}

	function startCensorDraw() {
		isDraggingCensor = true;
		censorStartPoint = screenPointToGrid(censorCanvas, mouseX, mouseY);
	}

	function endCensorDraw() {
		if (tempCensorRect && tempCensorRect.width > 0 && tempCensorRect.height > 0)
			addCensorRect(tempCensorRect);

		isDraggingCensor = false;
		censorStartPoint = null;
		tempCensorRect = null;

		drawCensorRects();
	}

	censorCanvas.addEventListener("wheel", (e) =>
		map.getCanvas().dispatchEvent(new WheelEvent(e.type, e))
	);

	window.addEventListener("mousedown", (e) => {
		if (
			(!censorMode && !settings.doInteractCensor) ||
			(e.target !== censorCanvas && e.target !== map.getCanvas())
		)
			return;
		if (censorMode) {
			if (e.button === 0) startCensorDraw();
			else if (e.button === 1) map.getCanvas().dispatchEvent(new MouseEvent(e.type, e));
		}
		e.preventDefault();
		e.stopPropagation();
		if (e.button === 2) {
			const point = screenPointToGrid(censorCanvas, e.clientX, e.clientY);
			const rects = getCensorRects();
			const rectIdx = rects.findIndex(
				(rect) =>
					point.gridX >= rect.gridX &&
					point.gridX < rect.gridX + rect.width &&
					point.gridY >= rect.gridY &&
					point.gridY < rect.gridY + rect.height
			);
			if (rectIdx !== -1) {
				if (e.ctrlKey) {
					const rect = rects[rectIdx];
					const isTop = rect.gridY + rect.height / 2 > point.gridY;
					const isLeft = rect.gridX + rect.width / 2 > point.gridX;
					isDraggingCensor = true;
					censorStartPoint = {
						gridX: rect.gridX + (isLeft ? rect.width : 0),
						gridY: rect.gridY + (isTop ? rect.height : 0),
					};
					oldCensorRect = rects.splice(rectIdx, 1)[0];
					saveCensorRects(rects);
					window.dispatchEvent(new MouseEvent("mousemove", e));
				} else if (confirm(`Remove censor rect?`)) {
					rects.splice(rectIdx, 1);
					saveCensorRects(rects);
				}
			}
		}
	});

	window.addEventListener("mousemove", (e) => {
		if (censorMode && e.buttons == 4) map.getCanvas().dispatchEvent(new MouseEvent(e.type, e));
		if ((!censorMode && !settings.doInteractCensor) || !isDraggingCensor || !censorStartPoint)
			return;

		e.preventDefault();
		e.stopPropagation();

		const point = screenPointToGrid(censorCanvas, e.clientX, e.clientY);

		const minX = Math.min(censorStartPoint.gridX, point.gridX);
		const maxX = Math.max(censorStartPoint.gridX, point.gridX);
		const minY = Math.min(censorStartPoint.gridY, point.gridY);
		const maxY = Math.max(censorStartPoint.gridY, point.gridY);

		tempCensorRect = {
			gridX: minX,
			gridY: minY,
			width: maxX - minX + 1,
			height: maxY - minY + 1,
		};

		drawCensorRects();
	});

	window.addEventListener("mouseup", (e) => {
		if (censorMode && e.button === 1) map.getCanvas().dispatchEvent(new MouseEvent(e.type, e));
		if (
			(!censorMode && !settings.doInteractCensor) ||
			!isDraggingCensor ||
			(e.button !== 0 && e.button !== 2)
		)
			return;
		e.preventDefault();
		e.stopPropagation();
		endCensorDraw();
	});

	window.addEventListener("mouseleave", () => {
		if (!censorMode && !settings.doInteractCensor) return;
		if (isDraggingCensor) {
			if (oldCensorRect) addCensorRect(oldCensorRect);
			oldCensorRect = null;
			isDraggingCensor = false;
			censorStartPoint = null;
			tempCensorRect = null;
			drawCensorRects();
		}
	});

	function drawCensorRect(ctx, rect, gSize, color, a = 1) {
		const topLeftMerc = [(rect.gridX - 0.5) * gSize, (rect.gridY - 0.5 + rect.height) * gSize];
		const bottomRightMerc = [(rect.gridX - 0.5 + rect.width) * gSize, (rect.gridY - 0.5) * gSize];

		const topLeftScreen = map.project(turf.toWgs84(topLeftMerc));
		const bottomRightScreen = map.project(turf.toWgs84(bottomRightMerc));

		const screenWidth = Math.abs(bottomRightScreen.x - topLeftScreen.x);
		const screenHeight = Math.abs(bottomRightScreen.y - topLeftScreen.y);

		if (
			topLeftScreen.x + screenWidth < 0 ||
			topLeftScreen.x > ctx.canvas.width ||
			topLeftScreen.y + screenHeight < 0 ||
			topLeftScreen.y > ctx.canvas.height
		)
			return;

		ctx.fillStyle = color;
		ctx.globalAlpha = a;
		ctx.fillRect(topLeftScreen.x, topLeftScreen.y, screenWidth, screenHeight);
	}
	//#endregion Drawing censor

	function getCensorRects() {
		if (!Array.isArray(censorRects)) {
			const c = localStorage.getItem(STORAGE_KEYS.censor);
			censorRects = c && isJsonString(c) ? JSON.parse(c) : [];
		}
		return censorRects;
	}
	function saveCensorRects(r) {
		censorRects = r;
		localStorage.setItem(STORAGE_KEYS.censor, JSON.stringify(r));
		drawCensorRects();
		document.dispatchEvent(new CustomEvent("censorRectsChanged"));
	}
	const addCensorRect = (rect) => saveCensorRects([...getCensorRects(), rect]);

	function drawCensorRects() {
		const pixelCanvas = document.getElementById("pixel-canvas");
		if (!pixelCanvas) return;

		censorCanvas.width = pixelCanvas.width;
		censorCanvas.height = pixelCanvas.height;
		const ctx = censorCanvas.getContext("2d");

		ctx.clearRect(0, 0, censorCanvas.width, censorCanvas.height);

		const gSize = usw.gridSize || gridSize || 25;

		if ((censorMode || settings.doInteractCensor) && isDraggingCensor && tempCensorRect)
			drawCensorRect(ctx, tempCensorRect, gSize, settings.censorColor, settings.censorAlpha / 1.5);

		const rects = getCensorRects();
		if (!rects.length) return;
		rects.forEach((rect) =>
			drawCensorRect(ctx, rect, gSize, settings.censorColor, settings.censorAlpha)
		);
	}

	function waitForMap(callback) {
		let tries = 0;
		function check() {
			if (map && map.on && map.getContainer) callback();
			else if (tries++ < 100) setTimeout(check, 100);
		}
		check();
	}

	waitForMap(() => {
		["move", "rotate", "zoom"].forEach((ev) => map.on(ev, drawCensorRects));
		new ResizeObserver(drawCensorRects).observe(map.getContainer());
		map.once("load", drawCensorRects);
	});
	//#endregion Censor

	//#region keybind
	document.addEventListener("mousemove", (e) => {
		mouseX = e.clientX;
		mouseY = e.clientY;
	});

	function isValidKeybindsConfig(json, doReturn = false) {
		const isValid =
			json &&
			typeof json === "object" &&
			Object.keys(json).every((key) => KEY_BINDINGS[key] && json[key].length <= 1);
		return doReturn ? (isValid ? json : false) : isValid;
	}

	let keybinds =
		isValidKeybindsConfig(JSON.parse(localStorage.getItem(STORAGE_KEYS.keybinds)), true) ||
		Object.fromEntries(
			Object.entries(KEY_BINDINGS)
				.filter(([_, v]) => v.defaultKey)
				.map(([id, { defaultKey }]) => [id, defaultKey])
		);

	Object.keys(KEY_BINDINGS).forEach((id) => {
		if (KEY_BINDINGS[id].defaultKey && !keybinds.hasOwnProperty(id))
			keybinds[id] = KEY_BINDINGS[id].defaultKey;
	});

	function saveKeybinds(kb) {
		keybinds = kb;
		localStorage.setItem(STORAGE_KEYS.keybinds, JSON.stringify(kb));
	}
	function saveKeybind(id, key) {
		keybinds[id] = key;
		saveKeybinds(keybinds);
	}

	document.getElementById("toggleKeybinds").addEventListener("click", () => {
		for (const key in keybinds) {
			const element = document.getElementById(`gpp_keybind-${key}`);
			if (element) element.value = keybinds[key];
		}
	});

	function handleKeyBindPress(e) {
		// Ignore shortcuts if typing in an input field.
		const aElm = document.activeElement;
		if (aElm && (aElm.tagName === "INPUT" || aElm.tagName === "TEXTAREA" || aElm.isContentEditable))
			return;

		const id = Object.keys(keybinds).find((key) => keybinds[key] === e.key.toLowerCase());
		if (id && KEY_BINDINGS[id][e.type]) {
			KEY_BINDINGS[id][e.type](e);
			e.preventDefault();
		}
	}

	document.addEventListener("keydown", handleKeyBindPress);
	document.addEventListener("keyup", handleKeyBindPress);

	document
		.querySelector("#keybindsPanel>div.flex>button.bg-blue-500")
		.addEventListener("click", () => {
			saveKeybinds(
				Object.fromEntries(
					Object.keys(KEY_BINDINGS).map((id) => [
						id,
						document.querySelector(`[id^='gpp_keybind-${id}']`).value.trim().toLowerCase(),
					])
				)
			);
		});
	//#endregion keybind

	//#region settings
	function isValidSettingsConfig(json, doReturn = false) {
		const isValid =
			json && typeof json === "object" && Object.keys(json).every((key) => SETTINGS[key]);
		return doReturn ? (isValid ? json : false) : isValid;
	}

	let settings =
		isValidSettingsConfig(JSON.parse(localStorage.getItem(STORAGE_KEYS.settings)), true) ||
		Object.fromEntries(
			Object.entries(SETTINGS)
				.filter(([_, v]) => v.default)
				.map(([id, { default: d }]) => [id, d])
		);

	Object.keys(SETTINGS).forEach((id) => {
		if (SETTINGS[id].default !== undefined && !settings.hasOwnProperty(id))
			settings[id] = SETTINGS[id].default;
	});

	function saveSetting(id, val) {
		settings[id] = val;
		localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings));
	}

	for (const [id, val] of Object.entries(settings))
		if (SETTINGS[id] && SETTINGS[id].apply) SETTINGS[id].apply(val);

	const SETTINGS_CLASSES = {
		select: "rounded-md border-2 border-gray-200 sm:text-sm p-2",
		textarea:
			"w-full h-32 rounded-md border-2 border-gray-200 p-2 font-mono resize-vertical outline-none transition",
		button:
			"px-4 py-2 rounded-lg shadow transition cursor-pointer bg-blue-500 hover:bg-blue-600 text-white",
		input: {
			color: "rounded-md border-2 border-gray-200 p-1 cursor-pointer",
			range: "py-1.5 cursor-pointer",
			keybind: "keybind-input",
			checkbox: "cursor-pointer h-6 w-6",
		},
	};
	function makeSettingUI(key, s) {
		const baseClass =
			typeof SETTINGS_CLASSES[s.element] === "string"
				? SETTINGS_CLASSES[s.element]
				: SETTINGS_CLASSES[s.element]?.[s.attributes.type] ?? "";

		const input = Object.assign(document.createElement(s.element), {
			className: baseClass,
			...s.attributes,
			[s.attributes.type === "checkbox" ? "checked" : "value"]: s.value?.() ?? settings[key],
		});

		if (s.init) s.init(input);

		if (s.element == "button") {
			input.innerText = s.name;
			return input;
		}

		if (s.element == "select") {
			input.append(
				...s.options.map(({ value, text }) =>
					Object.assign(document.createElement("option"), {
						value,
						innerText: text,
					})
				)
			);
		}

		const label = document.createElement("label");
		label.className = "text-sm font-medium text-gray-700 flex";

		const isKeybind = s.attributes.type == "keybind";
		const isRow = isKeybind || s.attributes.type == "checkbox";

		if (isKeybind) {
			input.maxLength = 1;
			label.style.flex = "0 1 calc(50% - var(--spacing))";
		}
		if (isRow) {
			label.className += " flex-row items-center gap-1";
			label.append(input, s.name);
		} else {
			label.className += " flex-col items-start";
			label.append(s.name, input);
		}

		if (s.element == "textarea") label.className += " w-full";

		if (s.reset || s.default !== undefined) {
			const resetButton = makeButton("⟲", () => {
				if (s.reset) s.reset(input);
				else {
					input.value = s.default;
					input.dispatchEvent(new Event("change"));
				}
			});
			resetButton.className = "cursor-pointer absolute top-0 h-5 w-5 text-xl";
			resetButton.style.lineHeight = "var(--text-xl)";
			resetButton.title = "reset";
			label.appendChild(resetButton);
			label.className += " relative";
			if (isKeybind) {
				label.style.paddingLeft = "calc(var(--text-xl))";
				resetButton.className += " left-0";
			} else {
				label.style.paddingRight = "calc(var(--text-xl) + 5px)";
				resetButton.className += " right-0";
			}
		}
		return label;
	}
	//#endregion settings

	//#region Ghost colors progress
	function updateGhostPaletteProgress() {
		if (!ghostImage || !ghostImageOriginalData || !ghostImageTopLeft) return;

		const { gridX, gridY } = ghostImageTopLeft;
		const { width, height } = ghostImage;

		const mapCanvas = Object.assign(document.createElement("canvas"), { width, height });
		const mapCtx = mapCanvas.getContext("2d");

		mapCtx.setTransform(1, 0, 0, -1, -gridX, gridY + 1);

		for (const { x, y } of getAllCoordsBetween(
			{ x: snapToTile(gridX), y: snapToTile(gridY) },
			{ x: snapToTile(gridX + width), y: snapToTile(gridY - height) },
			SYNC_TILE_SIZE
		)) {
			const tileData = tileImageCache.get(`${x},${y}`);
			if (tileData?.colorBitmap) mapCtx.drawImage(tileData.colorBitmap, x, y);
		}

		mapCtx.setTransform(1, 0, 0, 1, 0, 0);

		const mapData = mapCtx.getImageData(0, 0, width, height).data;
		const ghostData = ghostImageOriginalData.data;
		const matchingColorMap = new Map();

		for (let i = 0; i < ghostData.length; i += 4) {
			if (ghostData[i + 3] <= 1) continue;
			const mapHex = rgbaToHex(mapData[i], mapData[i + 1], mapData[i + 2], mapData[i + 3]);
			const hex = rgbaToHex(ghostData[i], ghostData[i + 1], ghostData[i + 2], ghostData[i + 3]);
			if (hex == mapHex) matchingColorMap.set(hex, (matchingColorMap.get(hex) ?? 0) + 1);
		}

		const container = document.getElementById("ghostColorPalette");

		for (const { rgba, hex, totalCount } of ghostPaletteColors) {
			const swatch = container.querySelector(`[data-color-rgba="${rgba}"]`);
			if (!swatch) continue;
			swatch.style.position = "relative";

			const completed = matchingColorMap.get(toFullHex(hex)) ?? 0;
			const progress = completed / totalCount;
			const percent = Math.floor(progress * 1000) / 10;

			swatch.title = `${hex}\n${completed} / ${totalCount} pixels\n${percent}%`;

			let icon = swatch.querySelector("span");
			if (!icon) {
				icon = document.createElement("span");
				icon.className =
					"absolute h-3 w-3 rounded-full border-2 border-white text-white font-bold flex justify-center items-center";
				Object.assign(icon.style, { top: "-3px", right: "-3px", fontSize: "0.6rem" });
				swatch.appendChild(icon);
			}

			icon.textContent = progress === 1 ? "✓" : "";
			icon.style.backgroundColor =
				progress === 0 ? "#000" : `hsl(${360 - progress * 240}, 100%, 50%)`;
		}
	}

	const populateColorPaletteUI_old = usw.populateColorPaletteUI;
	usw.populateColorPaletteUI = function () {
		populateColorPaletteUI_old();
		updateGhostPaletteProgress();
	};
	const calculateGhostProgress_old = usw.calculateGhostProgress;
	usw.calculateGhostProgress = function () {
		calculateGhostProgress_old();
		updateGhostPaletteProgress();
	};
	//#endregion Ghost colors progress

	//#region UI Helpers
	function copyToClipboard(text) {
		log(LOG_LEVELS.info, "Copied text to clipboard:\n", text);
		navigator.clipboard
			.writeText(text)
			.then(() => {
				alert("Copied to clipboard!");
			})
			.catch((err) => {
				log(LOG_LEVELS.error, "Failed to copy:", err);
				alert("Failed to copy to clipboard");
			});
	}

	function createModal(title, inner, footer) {
		const modalCloseTimeout = 0.15;

		const modalContainer = document.createElement("div");
		modalContainer.className = "fixed inset-0 z-50 bg-black/50 flex items-center justify-center";

		const modal = document.createElement("div");
		modal.className =
			"relative bg-white rounded-2xl shadow-2xl p-6 flex flex-col gap-6 max-h-[90vh]";
		modal.style.transition = `transform ${modalCloseTimeout}s ease-out`;
		modal.style.minWidth = "min(500px,90vw)";
		modal.style.maxWidth = "90vw";

		modal.close = () => {
			document.removeEventListener("keydown", escHandler);
			modal.style.transform = "scale(0)";
			setTimeout(() => {
				document.body.removeChild(modalContainer);
			}, modalCloseTimeout * 1000);
		};

		const modalCrossBtn = document.createElement("button");
		modalCrossBtn.className =
			"absolute top-4 right-4 w-10 h-10 bg-white shadow rounded-full hover:bg-gray-100 cursor-pointer";
		modalCrossBtn.innerText = "✕";
		modalCrossBtn.onclick = () => modal.close();

		const modalTitle = document.createElement("h2");
		modalTitle.className = "text-2xl font-semibold text-gray-800";
		modalTitle.innerText = title;

		inner = Array.isArray(inner) ? inner : [inner];
		modal.append(modalCrossBtn, modalTitle, ...inner);

		if (footer) {
			const footerWrapper = document.createElement("div");
			footerWrapper.className = "flex justify-end gap-3";
			footer = Array.isArray(footer) ? footer : [footer];
			footerWrapper.append(...footer);
			modal.appendChild(footerWrapper);
		}
		modalContainer.appendChild(modal);

		modal.onclick = (e) => e.stopPropagation();
		modalContainer.onclick = () => modal.close();
		const escHandler = (e) => e.key === "Escape" && modal.close();
		document.addEventListener("keydown", escHandler);

		document.body.appendChild(modalContainer);
		return modal;
	}

	function promptForColors(onSubmit, title = "Enter colors") {
		const textarea = document.createElement("textarea");
		textarea.placeholder =
			"Enter colors (one per line)\nFormat: '#?RRGGBB(.+)?'\nExample:\nFAECCE\n#FAECCE55\n#faecce\nFAECCE55 randomTEXTatgfdljj";
		textarea.className =
			"w-full p-3 border-2 border-gray-200 rounded-lg font-mono text-sm resize-vertical box-border";
		textarea.style.height = "200px";

		const modal = createModal(
			title,
			textarea,
			makeBasicButton("Submit", COLOR_CLASS.blue, () => {
				const value = textarea.value.trim();
				log(LOG_LEVELS.debug, "Submitted colors:\n", value);
				if (value) onSubmit(colorsStringToHexArray(value));
				modal.close();
			})
		);

		textarea.focus();
	}

	function makeButton(innerText, onClick) {
		const button = document.createElement("button");
		button.innerText = innerText;
		button.onclick = onClick;
		return button;
	}
	const COLOR_CLASS = {
		red: "bg-red-500 hover:bg-red-600 text-white",
		green: "bg-green-500 hover:bg-green-600 text-white",
		blue: "bg-blue-500 hover:bg-blue-600 text-white",
		gray: "bg-gray-200 hover:bg-gray-300 text-gray-700",
	};
	function makeBasicButton(innerText, classes = COLOR_CLASS.blue, onClick) {
		if (typeof classes === "function") {
			onClick = classes;
			classes = COLOR_CLASS.blue;
		}
		const button = makeButton(innerText, onClick);
		button.className = `px-4 py-2 rounded-lg shadow transition cursor-pointer ${classes}`;
		return button;
	}
	function makeSelectButton(innerText, onClick) {
		const button = makeButton(innerText, onClick);
		button.className =
			"px-2 py-1 bg-white rounded-lg shadow hover:bg-gray-300 border border-gray-300 transition cursor-pointer";
		return button;
	}
	function makeMenuButton(innerText, title, onClick) {
		const button = makeButton(innerText, onClick);
		button.className =
			"w-10 h-10 bg-white shadow rounded-full flex items-center justify-center hover:bg-gray-100 cursor-pointer transition-all";
		button.title = title;
		return button;
	}
	function makeSelectMenuButton(innerText, title, options, optionsTitle = "") {
		const wrapper = document.createElement("div");
		const select = document.createElement("div");
		const menuButton = makeMenuButton(innerText, title, () => select.toggle());

		wrapper.className = "relative";
		wrapper.onclick = (e) => e.stopPropagation();

		if (optionsTitle.length) select.innerHTML = `<h2>${optionsTitle}</h2>`;
		select.append(...options.map((opt) => makeSelectButton(opt.innerText, opt.onClick)));
		select.classList =
			"bg-white shadow rounded-xl p-2 border border-gray-300 transition-all absolute w-auto top-0 overflow-hidden flex flex-col gap-1";
		select.style.height = "auto";
		select.style.whiteSpace = "nowrap";
		select.close = () => {
			select.dataset.isOpened = "false";
			select.style.maxWidth = "40px";
			select.style.maxHeight = "40px";
			select.style.zIndex = "-1";
			select.style.left = "0px";
			menuButton.classList.remove("rounded-full");
			menuButton.classList.add("rounded-xl");
		};
		select.open = () => {
			select.dataset.isOpened = "true";
			select.style.maxWidth = "500px";
			select.style.maxHeight = (optionsTitle.length ? 25 : 0) + 16 + options.length * 40 + "px";
			select.style.zIndex = "1";
			select.style.left = "50px";
			menuButton.classList.add("rounded-full");
			menuButton.classList.remove("rounded-xl");
		};
		select.toggle = () => {
			if (select.dataset.isOpened == "true") select.close();
			else select.open();
		};
		select.close();
		window.addEventListener("click", (e) => e.target !== menuButton && select.close(), true);

		wrapper.append(menuButton, select);
		return wrapper;
	}
	function makeMenuGroupButton(innerText, title, id, parent) {
		const div = document.createElement("div");
		div.className = "relative";
		const button = makeMenuButton(innerText, title, (e) => {
			e.stopPropagation();
			closeAllDropdowns();
			toggleDropdown(document.getElementById(`${id}Dropdown`));
		});
		button.id = `${id}Btn`;
		const dropdown = document.createElement("div");
		dropdown.className =
			"dropdown-menu absolute left-0 mt-2 flex flex-col gap-2 transition-all duration-150 ease-out hidden";
		dropdown.id = `${id}Dropdown`;
		div.append(button, dropdown);
		parent.appendChild(div);
		return dropdown;
	}

	function addGPPButtonToggle(parent, ...elms) {
		const button = makeButton("✚₊", () => button.toggle());
		button.open = () => {
			button.dataset.expanded = "true";
			button.style.transform = "rotateZ(-180deg)";
			elms.forEach((b) => {
				b.style.display = "";
			});
		};
		button.close = () => {
			button.dataset.expanded = "false";
			button.style.transform = "rotateZ(0)";
			elms.forEach((b) => {
				b.style.display = "none";
			});
		};
		button.toggle = () => {
			if (button.dataset.expanded === "true") button.close();
			else button.open();
		};
		button.close();
		button.className =
			"text-white shadow transition cursor-pointer bg-yellow-500 hover:bg-yellow-600 rounded-full h-10 w-10";

		parent.append(button, ...elms);
	}
	//#endregion UI Helpers

	//#region UI
	function createCensorsConfigModal() {
		const censorList = document.createElement("div");
		censorList.className = "flex flex-col overflow-y-auto gap-1";

		createModal("Censor Configurations", censorList, [
			makeBasicButton("Add Censor Rect", COLOR_CLASS.green, () => {
				addCensorRect({ gridX: 0, gridY: 0, width: 1, height: 1 });
				refreshCensorList();
			}),
			makeBasicButton("Clear All", COLOR_CLASS.red, () => {
				if (confirm("Are you sure you want to clear all censor rectangles?")) {
					saveCensorRects();
					refreshCensorList();
				}
			}),
		]);

		function refreshCensorList() {
			const rects = getCensorRects();
			if (rects.length === 0) {
				censorList.innerHTML = "No censor rectangles defined.";
				return;
			}
			const rectDatas = ["gridX", "gridY", "width", "height"];
			censorList.innerHTML = "";
			censorList.append(
				...rects.map((r, idx) => {
					const row = document.createElement("div");
					row.className =
						"flex justify-between items-center flex-wrap p-2 border border-gray-300 rounded-lg gap-3";
					row.append(
						...rectDatas.map((prop) => {
							const label = document.createElement("label");
							label.innerText = `${prop}:`;
							const input = document.createElement("input");
							input.type = "number";
							input.value = r[prop];
							input.className = "w-28 border border-gray-300 rounded-md px-1 outline-none";
							input.onchange = (e) => {
								const rects = getCensorRects();
								if (rects[idx] && parseInt(e.target.value)) {
									rects[idx][prop] = parseInt(e.target.value);
									saveCensorRects(rects);
								}
							};
							label.append(input);
							return label;
						}),
						Object.assign(
							makeButton("🗑️", () => {
								const rects = getCensorRects();
								if (rects[idx]) {
									rects.splice(idx, 1);
									saveCensorRects(rects);
									refreshCensorList();
								}
							}),
							{ className: `p-1 rounded-lg transition cursor-pointer ${COLOR_CLASS.red}` }
						)
					);
					return row;
				})
			);
		}
		refreshCensorList();
	}

	function createSettingsModal() {
		const groupedSettings = {};
		for (const [key, setting] of Object.entries(SETTINGS)) {
			if (!groupedSettings[setting.category]) groupedSettings[setting.category] = [];
			groupedSettings[setting.category].push({ key, setting });
		}

		const div = document.createElement("div");
		div.className = "flex flex-wrap gap-2 items-end max-w-2xl overflow-y-auto";
		Object.entries(groupedSettings).forEach(([category, sets]) => {
			const categoryTitle = document.createElement("h3");
			categoryTitle.className =
				"w-full text-xl font-semibold text-gray-700 mt-4 mb-2 border-b border-gray-300";
			categoryTitle.innerText = category;
			div.append(categoryTitle, ...sets.map(({ key, setting }) => makeSettingUI(key, setting)));
		});

		createModal("Settings", div);
	}

	// Add buttons to ghost UI
	addGPPButtonToggle(
		document.querySelector("#ghostColorPaletteContainer>div"),
		makeBasicButton("Enable Only Owned Ghost Colors", () =>
			setEnabledGhostPalette(getAvailableGhostColors())
		),
		makeBasicButton("Get Ghost Colors", () =>
			copyToClipboard(
				getGhostImageHexColors()
					.map((hex) => toOutputHex(hex))
					.join("\n")
			)
		),
		makeBasicButton("Get Enabled Ghost Colors", () =>
			copyToClipboard(getEnabledGhostPalette().toOutputString())
		),
		makeBasicButton("Set Enabled Ghost Colors", () =>
			promptForColors(
				(t) => setEnabledGhostPalette(t.filter(isInGhostPalette)),
				"Set Enabled Ghost Colors"
			)
		)
	);

	// Add buttons to profile
	const shopButtonsContainer = document.querySelector("#userColorsContainer + div");
	if (shopButtonsContainer) {
		shopButtonsContainer.style.display = "flex";
		shopButtonsContainer.style.flexWrap = "wrap";
		shopButtonsContainer.style.gap = "8px";

		addGPPButtonToggle(
			shopButtonsContainer,
			makeBasicButton("Get User Colors", () => copyToClipboard(getUserPalette().toOutputString())),
			makeBasicButton("Get Enabled Colors", () =>
				copyToClipboard(
					getUserPalette()
						.filter((c, idx) => activeColors.includes(idx))
						.toOutputString()
				)
			),
			makeBasicButton("Set Enabled Colors", () =>
				promptForColors(
					(t) => setEnabledUserPalette(t.filter(isInUserPalette)),
					"Set Enabled User Colors"
				)
			)
		);
	}

	//create hidden file input
	const inputSoundFile = document.createElement("input");
	inputSoundFile.type = "file";
	inputSoundFile.style.display = "none";
	inputSoundFile.accept = "audio/*";
	inputSoundFile.onchange = () => {
		let blob = new Blob([inputSoundFile.files[0]]);
		saveBlobSound(SOUNDS[soundToChangeIdx], blob);
	};
	document.body.appendChild(inputSoundFile);

	// Add GeoPixels++ menu group to main UI
	makeMenuGroupButton(
		"✚₊",
		"GeoPixels++",
		"geopixels-plusplus",
		document.getElementById("controls-left")
	).append(
		makeMenuButton("🎯", "Go to Coordinates", () => {
			const input = prompt("Enter coordinates (gridX,gridY) or GeoPixels URL:");
			if (input) gotoFromInput(input);
		}),
		makeMenuButton("🧪", "Set Both Palettes", () =>
			promptForColors((s) => {
				setEnabledUserPalette(s.filter(isInUserPalette));
				setEnabledGhostPalette(s.filter(isInGhostPalette));
			})
		),
		makeSelectMenuButton(
			"🎵",
			"Change Sound",
			SOUNDS.map((s, i) => {
				return {
					innerText: s.name,
					onClick: () => {
						soundToChangeIdx = i;
						inputSoundFile.click();
					},
				};
			}),
			"Choose a sound to change"
		),
		makeSelectMenuButton("🚫", "Censor", [
			{
				innerText: "Toggle censor mode",
				onClick: toggleCensorMode,
			},
			{
				innerText: "Open censors config",
				onClick: createCensorsConfigModal,
			},
		]),
		makeMenuButton("⚙️", "Settings", createSettingsModal)
	);

	//Add to the keybind Panel
	const gpp_keybind_div = document.createElement("div");
	gpp_keybind_div.innerHTML = `
			<h3 class="text-lg font-semibold text-gray-700 mb-2">GeoPixels++</h3>
			<div class="space-y-2">
				${Object.keys(KEY_BINDINGS)
					.map(
						(id) => `
							<div class="flex items-center justify-between p-2">
								<label for="gpp_keybind-${id}" class="text-gray-600">${KEY_BINDINGS[id].text}</label>
								<input id="gpp_keybind-${id}" type="text" maxlength="1" class="keybind-input">
							</div>
						`
					)
					.join("")}
			</div>
	`;
	document.querySelector("#keybindsPanel>div.grid>div:nth-child(2)").appendChild(gpp_keybind_div);

	//fix some styles
	const style = document.createElement("style");
	style.innerHTML = `input[type="number"]{appearance:auto;}`;
	document.head.appendChild(style);

	//#endregion UI

	log(LOG_LEVELS.info, "GeoPixels++ loaded");
})();