mirror of
https://github.com/SwingTheVine/Wplace-BlueMarble.git
synced 2026-05-07 20:59:50 +00:00
772 lines
41 KiB
JavaScript
772 lines
41 KiB
JavaScript
/** @file The main file. Everything in the userscript is executed from here.
|
|
* @since 0.0.0
|
|
*/
|
|
|
|
import Overlay from './Overlay.js';
|
|
import Observers from './observers.js';
|
|
import ApiManager from './apiManager.js';
|
|
import TemplateManager from './templateManager.js';
|
|
import { calculateRelativeLuminance, consoleLog, consoleWarn } from './utils.js';
|
|
|
|
const name = GM_info.script.name.toString(); // Name of userscript
|
|
const version = GM_info.script.version.toString(); // Version of userscript
|
|
const consoleStyle = 'color: cornflowerblue;'; // The styling for the console logs
|
|
|
|
/** Injects code into the client
|
|
* This code will execute outside of TamperMonkey's sandbox
|
|
* @param {*} callback - The code to execute
|
|
* @since 0.11.15
|
|
*/
|
|
function inject(callback) {
|
|
const script = document.createElement('script');
|
|
script.setAttribute('bm-name', name); // Passes in the name value
|
|
script.setAttribute('bm-cStyle', consoleStyle); // Passes in the console style value
|
|
script.textContent = `(${callback})();`;
|
|
document.documentElement?.appendChild(script);
|
|
script.remove();
|
|
}
|
|
|
|
/** What code to execute instantly in the client (webpage) to spy on fetch calls.
|
|
* This code will execute outside of TamperMonkey's sandbox.
|
|
* @since 0.11.15
|
|
*/
|
|
inject(() => {
|
|
|
|
const script = document.currentScript; // Gets the current script HTML Script Element
|
|
const name = script?.getAttribute('bm-name') || 'Blue Marble'; // Gets the name value that was passed in. Defaults to "Blue Marble" if nothing was found
|
|
const consoleStyle = script?.getAttribute('bm-cStyle') || ''; // Gets the console style value that was passed in. Defaults to no styling if nothing was found
|
|
const fetchedBlobQueue = new Map(); // Blobs being processed
|
|
|
|
window.addEventListener('message', (event) => {
|
|
const { source, endpoint, blobID, blobData, blink } = event.data;
|
|
|
|
const elapsed = Date.now() - blink;
|
|
|
|
// Since this code does not run in the userscript, we can't use consoleLog().
|
|
console.groupCollapsed(`%c${name}%c: ${fetchedBlobQueue.size} Recieved IMAGE message about blob "${blobID}"`, consoleStyle, '');
|
|
console.log(`Blob fetch took %c${String(Math.floor(elapsed/60000)).padStart(2,'0')}:${String(Math.floor(elapsed/1000) % 60).padStart(2,'0')}.${String(elapsed % 1000).padStart(3,'0')}%c MM:SS.mmm`, consoleStyle, '');
|
|
console.log(fetchedBlobQueue);
|
|
console.groupEnd();
|
|
|
|
// The modified blob won't have an endpoint, so we ignore any message without one.
|
|
if ((source == 'blue-marble') && !!blobID && !!blobData && !endpoint) {
|
|
|
|
const callback = fetchedBlobQueue.get(blobID); // Retrieves the blob based on the UUID
|
|
|
|
// If the blobID is a valid function...
|
|
if (typeof callback === 'function') {
|
|
|
|
callback(blobData); // ...Retrieve the blob data from the blobID function
|
|
} else {
|
|
// ...else the blobID is unexpected. We don't know what it is, but we know for sure it is not a blob. This means we ignore it.
|
|
|
|
consoleWarn(`%c${name}%c: Attempted to retrieve a blob (%s) from queue, but the blobID was not a function! Skipping...`, consoleStyle, '', blobID);
|
|
}
|
|
|
|
fetchedBlobQueue.delete(blobID); // Delete the blob from the queue, because we don't need to process it again
|
|
}
|
|
});
|
|
|
|
// Spys on "spontaneous" fetch requests made by the client
|
|
const originalFetch = window.fetch; // Saves a copy of the original fetch
|
|
|
|
// Overrides fetch
|
|
window.fetch = async function(...args) {
|
|
|
|
const response = await originalFetch.apply(this, args); // Sends a fetch
|
|
const cloned = response.clone(); // Makes a copy of the response
|
|
|
|
// Retrieves the endpoint name. Unknown endpoint = "ignore"
|
|
const endpointName = ((args[0] instanceof Request) ? args[0]?.url : args[0]) || 'ignore';
|
|
|
|
// Check Content-Type to only process JSON
|
|
const contentType = cloned.headers.get('content-type') || '';
|
|
if (contentType.includes('application/json')) {
|
|
|
|
|
|
// Since this code does not run in the userscript, we can't use consoleLog().
|
|
console.log(`%c${name}%c: Sending JSON message about endpoint "${endpointName}"`, consoleStyle, '');
|
|
|
|
// Sends a message about the endpoint it spied on
|
|
cloned.json()
|
|
.then(jsonData => {
|
|
window.postMessage({
|
|
source: 'blue-marble',
|
|
endpoint: endpointName,
|
|
jsonData: jsonData
|
|
}, '*');
|
|
})
|
|
.catch(err => {
|
|
console.error(`%c${name}%c: Failed to parse JSON: `, consoleStyle, '', err);
|
|
});
|
|
} else if (contentType.includes('image/') && (!endpointName.includes('openfreemap') && !endpointName.includes('maps'))) {
|
|
// Fetch custom for all images but opensourcemap
|
|
|
|
const blink = Date.now(); // Current time
|
|
|
|
const blob = await cloned.blob(); // The original blob
|
|
|
|
// Since this code does not run in the userscript, we can't use consoleLog().
|
|
console.log(`%c${name}%c: ${fetchedBlobQueue.size} Sending IMAGE message about endpoint "${endpointName}"`, consoleStyle, '');
|
|
|
|
// Returns the manipulated blob
|
|
return new Promise((resolve) => {
|
|
const blobUUID = crypto.randomUUID(); // Generates a random UUID
|
|
|
|
// Store the blob while we wait for processing
|
|
fetchedBlobQueue.set(blobUUID, (blobProcessed) => {
|
|
// The response that triggers when the blob is finished processing
|
|
|
|
// Creates a new response
|
|
resolve(new Response(blobProcessed, {
|
|
headers: cloned.headers,
|
|
status: cloned.status,
|
|
statusText: cloned.statusText
|
|
}));
|
|
|
|
// Since this code does not run in the userscript, we can't use consoleLog().
|
|
console.log(`%c${name}%c: ${fetchedBlobQueue.size} Processed blob "${blobUUID}"`, consoleStyle, '');
|
|
});
|
|
|
|
window.postMessage({
|
|
source: 'blue-marble',
|
|
endpoint: endpointName,
|
|
blobID: blobUUID,
|
|
blobData: blob,
|
|
blink: blink
|
|
});
|
|
}).catch(exception => {
|
|
const elapsed = Date.now();
|
|
console.error(`%c${name}%c: Failed to Promise blob!`, consoleStyle, '');
|
|
console.groupCollapsed(`%c${name}%c: Details of failed blob Promise:`, consoleStyle, '');
|
|
console.log(`Endpoint: ${endpointName}\nThere are ${fetchedBlobQueue.size} blobs processing...\nBlink: ${blink.toLocaleString()}\nTime Since Blink: ${String(Math.floor(elapsed/60000)).padStart(2,'0')}:${String(Math.floor(elapsed/1000) % 60).padStart(2,'0')}.${String(elapsed % 1000).padStart(3,'0')} MM:SS.mmm`);
|
|
console.error(`Exception stack:`, exception);
|
|
console.groupEnd();
|
|
});
|
|
|
|
// cloned.blob().then(blob => {
|
|
// window.postMessage({
|
|
// source: 'blue-marble',
|
|
// endpoint: endpointName,
|
|
// blobData: blob
|
|
// }, '*');
|
|
// });
|
|
}
|
|
|
|
return response; // Returns the original response
|
|
};
|
|
});
|
|
|
|
// Imports the CSS file from dist folder on github
|
|
const cssOverlay = GM_getResourceText("CSS-BM-File");
|
|
GM_addStyle(cssOverlay);
|
|
|
|
// Injection point for the Roboto Mono font file (only if this is the Standalone version)
|
|
const robotoMonoInjectionPoint = 'robotoMonoInjectionPoint';
|
|
|
|
// If the Roboto Mono injection point contains '@font-face'...
|
|
if (!!(robotoMonoInjectionPoint.indexOf('@font-face') + 1)) {
|
|
// A very hacky way of doing truthy/falsy logic
|
|
|
|
console.log(`Loading Roboto Mono as a file...`);
|
|
GM_addStyle(robotoMonoInjectionPoint); // Add the Roboto Mono font-faces that were injected.
|
|
} else {
|
|
// Else, no Roboto Mono was found. We need to use a stylesheet.
|
|
|
|
// Imports the Roboto Mono font family as a stylesheet
|
|
var stylesheetLink = document.createElement('link');
|
|
stylesheetLink.href = 'https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap';
|
|
stylesheetLink.rel = 'preload';
|
|
stylesheetLink.as = 'style';
|
|
stylesheetLink.onload = function () {
|
|
this.onload = null;
|
|
this.rel = 'stylesheet';
|
|
};
|
|
document.head?.appendChild(stylesheetLink);
|
|
}
|
|
|
|
// CONSTRUCTORS
|
|
const observers = new Observers(); // Constructs a new Observers object
|
|
const overlayMain = new Overlay(name, version); // Constructs a new Overlay object for the main overlay
|
|
const overlayTabTemplate = new Overlay(name, version); // Constructs a Overlay object for the template tab
|
|
const templateManager = new TemplateManager(name, version, overlayMain); // Constructs a new TemplateManager object
|
|
const apiManager = new ApiManager(templateManager); // Constructs a new ApiManager object
|
|
|
|
overlayMain.setApiManager(apiManager); // Sets the API manager
|
|
|
|
const storageTemplates = JSON.parse(GM_getValue('bmTemplates', '{}'));
|
|
console.log(storageTemplates);
|
|
templateManager.importJSON(storageTemplates); // Loads the templates
|
|
|
|
const userSettings = JSON.parse(GM_getValue('bmUserSettings', '{}')); // Loads the user settings
|
|
console.log(userSettings);
|
|
console.log(Object.keys(userSettings).length);
|
|
if (Object.keys(userSettings).length == 0) {
|
|
const uuid = crypto.randomUUID(); // Generates a random UUID
|
|
console.log(uuid);
|
|
GM.setValue('bmUserSettings', JSON.stringify({
|
|
'uuid': uuid
|
|
}));
|
|
}
|
|
setInterval(() => apiManager.sendHeartbeat(version), 1000 * 60 * 30); // Sends a heartbeat every 30 minutes
|
|
|
|
console.log(`Telemetry is ${!(userSettings?.telemetry == undefined)}`);
|
|
if ((userSettings?.telemetry == undefined) || (userSettings?.telemetry > 1)) { // Increment 1 to retrigger telemetry notice
|
|
const telemetryOverlay = new Overlay(name, version);
|
|
telemetryOverlay.setApiManager(apiManager); // Sets the API manager for the telemetry overlay
|
|
buildTelemetryOverlay(telemetryOverlay); // Notifies the user about telemetry
|
|
}
|
|
|
|
buildWindowMain(); // Builds the main Blue Marble window
|
|
|
|
apiManager.spontaneousResponseListener(overlayMain); // Reads spontaneous fetch responces
|
|
|
|
observeBlack(); // Observes the black palette color
|
|
|
|
consoleLog(`%c${name}%c (${version}) userscript has loaded!`, 'color: cornflowerblue;', '');
|
|
|
|
/** Observe the black color, and add the "Move" button.
|
|
* @since 0.66.3
|
|
*/
|
|
function observeBlack() {
|
|
const observer = new MutationObserver((mutations, observer) => {
|
|
|
|
const black = document.querySelector('#color-1'); // Attempt to retrieve the black color element for anchoring
|
|
|
|
if (!black) {return;} // Black color does not exist yet. Kills iteself
|
|
|
|
let move = document.querySelector('#bm-button-move'); // Tries to find the move button
|
|
|
|
// If the move button does not exist, we make a new one
|
|
if (!move) {
|
|
move = document.createElement('button');
|
|
move.id = 'bm-button-move';
|
|
move.textContent = 'Move ↑';
|
|
move.className = 'btn btn-soft';
|
|
move.onclick = function() {
|
|
const roundedBox = this.parentNode.parentNode.parentNode.parentNode; // Obtains the rounded box
|
|
const shouldMoveUp = (this.textContent == 'Move ↑');
|
|
roundedBox.parentNode.className = roundedBox.parentNode.className.replace(shouldMoveUp ? 'bottom' : 'top', shouldMoveUp ? 'top' : 'bottom'); // Moves the rounded box to the top
|
|
roundedBox.style.borderTopLeftRadius = shouldMoveUp ? '0px' : 'var(--radius-box)';
|
|
roundedBox.style.borderTopRightRadius = shouldMoveUp ? '0px' : 'var(--radius-box)';
|
|
roundedBox.style.borderBottomLeftRadius = shouldMoveUp ? 'var(--radius-box)' : '0px';
|
|
roundedBox.style.borderBottomRightRadius = shouldMoveUp ? 'var(--radius-box)' : '0px';
|
|
this.textContent = shouldMoveUp ? 'Move ↓' : 'Move ↑';
|
|
}
|
|
|
|
// Attempts to find the "Paint Pixel" element for anchoring
|
|
const paintPixel = black.parentNode.parentNode.parentNode.parentNode.querySelector('h2');
|
|
|
|
paintPixel.parentNode?.appendChild(move); // Adds the move button
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
/** Creates the main Blue Marble window.
|
|
* Parent/child relationships in the DOM structure below are indicated by indentation.
|
|
* @since 0.58.3
|
|
*/
|
|
function buildWindowMain() {
|
|
|
|
// Creates the window
|
|
overlayMain.addDiv({'id': 'bm-window-main', 'class': 'bm-window', 'style': 'top: 10px; left: unset; right: 75px;'})
|
|
.addDragbar()
|
|
.addButton({'class': 'bm-button-circle', 'textContent': '▼', 'aria-label': 'Minimize window "Blue Marble"', 'data-button-status': 'expanded'}, (instance, button) => {
|
|
button.onclick = () => instance.handleMinimization(button);
|
|
button.ontouchend = () => {button.click();}; // Needed only to negate weird interaction with dragbar
|
|
}).buildElement()
|
|
.addDiv().buildElement() // Contains the minimized h1 element
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-window-content'})
|
|
.addDiv({'class': 'bm-container'})
|
|
.addImg({'class': 'bm-favicon', 'src': 'https://raw.githubusercontent.com/SwingTheVine/Wplace-BlueMarble/main/dist/assets/Favicon.png'}).buildElement()
|
|
.addHeader(1, {'textContent': name}).buildElement()
|
|
.buildElement()
|
|
.addHr().buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addP({'id': 'bm-user-droplets', 'textContent': 'Droplets:'}).buildElement()
|
|
.addP({'id': 'bm-user-nextlevel', 'textContent': 'Next level in...'}).buildElement()
|
|
.buildElement()
|
|
.addHr().buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addDiv({'class': 'bm-container'})
|
|
.addButton({'class': 'bm-button-circle bm-button-pin', 'style': 'margin-top: 0;', 'innerHTML': '<svg viewBox="0 0 4 6"><path d="M.5,3.4A2,2 0 1 1 3.5,3.4L2,6"/><circle cx="2" cy="2" r=".7" fill="#fff"/></svg>'},
|
|
(instance, button) => {
|
|
button.onclick = () => {
|
|
const coords = instance.apiManager?.coordsTilePixel; // Retrieves the coords from the API manager
|
|
if (!coords?.[0]) {
|
|
instance.handleDisplayError('Coordinates are malformed! Did you try clicking on the canvas first?');
|
|
return;
|
|
}
|
|
instance.updateInnerHTML('bm-input-tx', coords?.[0] || '');
|
|
instance.updateInnerHTML('bm-input-ty', coords?.[1] || '');
|
|
instance.updateInnerHTML('bm-input-px', coords?.[2] || '');
|
|
instance.updateInnerHTML('bm-input-py', coords?.[3] || '');
|
|
}
|
|
}
|
|
).buildElement()
|
|
.addInput({'type': 'number', 'id': 'bm-input-tx', 'class': 'bm-input-coords', 'placeholder': 'Tl X', 'min': 0, 'max': 2047, 'step': 1, 'required': true}, (instance, input) => {
|
|
//if a paste happens on tx, split and format it into other coordinates if possible
|
|
input.addEventListener("paste", (event) => {
|
|
let splitText = (event.clipboardData || window.clipboardData).getData("text").split(" ").filter(n => n).map(Number).filter(n => !isNaN(n)); //split and filter all Non Numbers
|
|
if (splitText.length !== 4 ) { // If we don't have 4 clean coordinates, end the function.
|
|
return;
|
|
}
|
|
let coords = selectAllCoordinateInputs(document);
|
|
for (let i = 0; i < coords.length; i++) {
|
|
coords[i].value = splitText[i]; //add the split vales
|
|
}
|
|
event.preventDefault(); //prevent the pasting of the original paste that would overide the split value
|
|
})
|
|
}).buildElement()
|
|
.addInput({'type': 'number', 'id': 'bm-input-ty', 'class': 'bm-input-coords', 'placeholder': 'Tl Y', 'min': 0, 'max': 2047, 'step': 1, 'required': true}).buildElement()
|
|
.addInput({'type': 'number', 'id': 'bm-input-px', 'class': 'bm-input-coords', 'placeholder': 'Px X', 'min': 0, 'max': 2047, 'step': 1, 'required': true}).buildElement()
|
|
.addInput({'type': 'number', 'id': 'bm-input-py', 'class': 'bm-input-coords', 'placeholder': 'Px Y', 'min': 0, 'max': 2047, 'step': 1, 'required': true}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addInputFile({'class': 'bm-input-file', 'textContent': 'Upload Template', 'accept': 'image/png, image/jpeg, image/webp, image/bmp, image/gif'}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container bm-flex-between'})
|
|
.addButton({'textContent': 'Disable', 'data-button-status': 'shown'}, (instance, button) => {
|
|
button.onclick = () => {
|
|
button.disabled = true; // Disables the button until the transition ends
|
|
if (button.dataset['buttonStatus'] == 'shown') { // If templates are currently being 'shown' then hide them
|
|
instance.apiManager?.templateManager?.setTemplatesShouldBeDrawn(false); // Disables templates from being drawn
|
|
button.dataset['buttonStatus'] = 'hidden'; // Swap internal button status tracker
|
|
button.textContent = 'Enable'; // Swap button text
|
|
instance.handleDisplayStatus(`Disabled templates!`); // Inform the user
|
|
} else { // In all other cases, we should show templates instead of hiding them
|
|
instance.apiManager?.templateManager?.setTemplatesShouldBeDrawn(true); // Allows templates to be drawn
|
|
button.dataset['buttonStatus'] = 'shown'; // Swap internal button status tracker
|
|
button.textContent = 'Disable'; // Swap button text
|
|
instance.handleDisplayStatus(`Enabled templates!`); // Inform the user
|
|
}
|
|
button.disabled = false; // Enables the button
|
|
}
|
|
}).buildElement()
|
|
.addButton({'textContent': 'Create'}, (instance, button) => {
|
|
button.onclick = () => {
|
|
const input = document.querySelector('#bm-window-main button.bm-input-file');
|
|
|
|
// Checks to see if the coordinates are valid. Throws an error if they are not
|
|
const coordTlX = document.querySelector('#bm-input-tx');
|
|
if (!coordTlX.checkValidity()) {coordTlX.reportValidity(); instance.handleDisplayError('Coordinates are malformed! Did you try clicking on the canvas first?'); return;}
|
|
const coordTlY = document.querySelector('#bm-input-ty');
|
|
if (!coordTlY.checkValidity()) {coordTlY.reportValidity(); instance.handleDisplayError('Coordinates are malformed! Did you try clicking on the canvas first?'); return;}
|
|
const coordPxX = document.querySelector('#bm-input-px');
|
|
if (!coordPxX.checkValidity()) {coordPxX.reportValidity(); instance.handleDisplayError('Coordinates are malformed! Did you try clicking on the canvas first?'); return;}
|
|
const coordPxY = document.querySelector('#bm-input-py');
|
|
if (!coordPxY.checkValidity()) {coordPxY.reportValidity(); instance.handleDisplayError('Coordinates are malformed! Did you try clicking on the canvas first?'); return;}
|
|
|
|
// Kills itself if there is no file
|
|
if (!input?.files[0]) {instance.handleDisplayError(`No file selected!`); return;}
|
|
|
|
templateManager.createTemplate(input.files[0], input.files[0]?.name.replace(/\.[^/.]+$/, ''), [Number(coordTlX.value), Number(coordTlY.value), Number(coordPxX.value), Number(coordPxY.value)]);
|
|
instance.handleDisplayStatus(`Drew to canvas!`);
|
|
}
|
|
}).buildElement()
|
|
.addButton({'textContent': 'Filter'}, (instance, button) => {
|
|
button.onclick = () => buildWindowFilter();
|
|
}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addTextarea({'id': overlayMain.outputStatusId, 'placeholder': `Status: Sleeping...\nVersion: ${version}`, 'readOnly': true}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container bm-flex-between', 'style': 'margin-bottom: 0;'})
|
|
.addDiv({'class': 'bm-flex-between'})
|
|
// .addButton({'class': 'bm-button-circle', 'innerHTML': '🖌'}).buildElement()
|
|
.addButton({'class': 'bm-button-circle', 'innerHTML': '🎨', 'title': 'Template Color Converter'}, (instance, button) => {
|
|
button.onclick = () => {
|
|
window.open('https://pepoafonso.github.io/color_converter_wplace/', '_blank', 'noopener noreferrer');
|
|
}
|
|
}).buildElement()
|
|
.addButton({'class': 'bm-button-circle', 'innerHTML': '🌐', 'title': 'Official Blue Marble Website'}, (instance, button) => {
|
|
button.onclick = () => {
|
|
window.open('https://bluemarble.lol/', '_blank', 'noopener noreferrer');
|
|
}
|
|
}).buildElement()
|
|
.buildElement()
|
|
.addSmall({'textContent': 'Made by SwingTheVine', 'style': 'margin-top: auto;'}).buildElement()
|
|
.buildElement()
|
|
.buildElement()
|
|
.buildElement()
|
|
.buildElement().buildOverlay(document.body);
|
|
|
|
// Creates dragging capability on the drag bar for dragging the window
|
|
overlayMain.handleDrag('#bm-window-main.bm-window', '#bm-window-main .bm-dragbar');
|
|
}
|
|
|
|
function buildTelemetryOverlay(overlay) {
|
|
overlay.addDiv({'id': 'bm-overlay-telemetry', style: 'top: 0px; left: 0px; width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; z-index: 9999;'})
|
|
.addDiv({'id': 'bm-contain-all-telemetry', style: 'display: flex; flex-direction: column; align-items: center;'})
|
|
.addDiv({'id': 'bm-contain-header-telemetry', style: 'margin-top: 10%;'})
|
|
.addHeader(1, {'textContent': `${name} Telemetry`}).buildElement()
|
|
.buildElement()
|
|
|
|
.addDiv({'id': 'bm-contain-telemetry', style: 'max-width: 50%; overflow-y: auto; max-height: 80vh;'})
|
|
.addHr().buildElement()
|
|
.addBr().buildElement()
|
|
.addDiv({'style': 'width: fit-content; margin: auto; text-align: center;'})
|
|
.addButton({'id': 'bm-button-telemetry-more', 'textContent': 'More Information'}, (instance, button) => {
|
|
button.onclick = () => {
|
|
window.open('https://github.com/SwingTheVine/Wplace-TelemetryServer#telemetry-data', '_blank', 'noopener noreferrer');
|
|
}
|
|
}).buildElement()
|
|
.buildElement()
|
|
.addBr().buildElement()
|
|
.addDiv({style: 'width: fit-content; margin: auto; text-align: center;'})
|
|
.addButton({'id': 'bm-button-telemetry-enable', 'textContent': 'Enable Telemetry', 'style': 'margin-right: 2ch;'}, (instance, button) => {
|
|
button.onclick = () => {
|
|
const userSettings = JSON.parse(GM_getValue('bmUserSettings', '{}'));
|
|
userSettings.telemetry = 1;
|
|
GM.setValue('bmUserSettings', JSON.stringify(userSettings));
|
|
const element = document.getElementById('bm-overlay-telemetry');
|
|
if (element) {
|
|
element.style.display = 'none';
|
|
}
|
|
}
|
|
}).buildElement()
|
|
.addButton({'id': 'bm-button-telemetry-disable', 'textContent': 'Disable Telemetry'}, (instance, button) => {
|
|
button.onclick = () => {
|
|
const userSettings = JSON.parse(GM_getValue('bmUserSettings', '{}'));
|
|
userSettings.telemetry = 0;
|
|
GM.setValue('bmUserSettings', JSON.stringify(userSettings));
|
|
const element = document.getElementById('bm-overlay-telemetry');
|
|
if (element) {
|
|
element.style.display = 'none';
|
|
}
|
|
}
|
|
}).buildElement()
|
|
.buildElement()
|
|
.addBr().buildElement()
|
|
.addP({'textContent': 'We collect anonymous telemetry data such as your browser, OS, and script version to make the experience better for everyone. The data is never shared personally. The data is never sold. You can turn this off by pressing the \'Disable\' button, but keeping it on helps us improve features and reliability faster. Thank you for supporting the Blue Marble!'}).buildElement()
|
|
.addP({'textContent': 'You can disable telemetry by pressing the "Disable" button below.'}).buildElement()
|
|
.buildElement()
|
|
.buildElement()
|
|
.buildOverlay(document.body);
|
|
}
|
|
|
|
/** Spawns a Color Filter window.
|
|
* If another color filter window already exists, we DON'T spawn another!
|
|
* Parent/child relationships in the DOM structure below are indicated by indentation.
|
|
* @since 0.88.149
|
|
*/
|
|
function buildWindowFilter() {
|
|
|
|
// If a color filter window already exists, throw an error and return early
|
|
if (document.querySelector('#bm-window-filter')) {
|
|
overlayMain.handleDisplayError('Color Filter window already exists!');
|
|
return;
|
|
}
|
|
|
|
// Eye icons
|
|
const eyeOpen = '<svg viewBox="0 .5 6 3"><path d="M0,2Q3-1 6,2Q3,5 0,2H2A1,1 0 1 0 3,1Q3,2 2,2"/></svg>';
|
|
const eyeClosed = '<svg viewBox="0 1 12 6"><mask id="a"><path d="M0,0H12V8L0,2" fill="#fff"/></mask><path d="M0,4Q6-2 12,4Q6,10 0,4H4A2,2 0 1 0 6,2Q6,4 4,4ZM1,2L10,6.5L9.5,7L.5,2.5" mask="url(#a)"/></svg>';
|
|
|
|
const localizeNumber = new Intl.NumberFormat();
|
|
const localizePercent = new Intl.NumberFormat(undefined, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
// Creates a new color filter window
|
|
const overlayFilter = new Overlay(name, version);
|
|
overlayFilter.addDiv({'id': 'bm-window-filter', 'class': 'bm-window'})
|
|
.addDragbar()
|
|
.addButton({'class': 'bm-button-circle', 'textContent': '▼', 'aria-label': 'Minimize window "Color Filter"', 'data-button-status': 'expanded'}, (instance, button) => {
|
|
button.onclick = () => instance.handleMinimization(button);
|
|
button.ontouchend = () => {button.click()}; // Needed only to negate weird interaction with dragbar
|
|
}).buildElement()
|
|
.addDiv().buildElement() // Contains the minimized h1 element
|
|
.addButton({'class': 'bm-button-circle', 'textContent': '🞪', 'aria-label': 'Close window "Color Filter"'}, (instance, button) => {
|
|
button.onclick = () => {document.querySelector('#bm-window-filter')?.remove();};
|
|
button.ontouchend = () => {button.click();}; // Needed only to negate weird interaction with dragbar
|
|
}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-window-content'})
|
|
.addDiv({'class': 'bm-container bm-center-vertically'})
|
|
.addHeader(1, {'textContent': 'Color Filter'}).buildElement()
|
|
.buildElement()
|
|
.addHr().buildElement()
|
|
.addDiv({'class': 'bm-container bm-flex-between', 'style': 'gap: 1.5ch; width: fit-content; margin-left: auto; margin-right: auto;'})
|
|
.addButton({'textContent': 'Select All'}, (instance, button) => {
|
|
button.onclick = () => selectColorList(false);
|
|
}).buildElement()
|
|
.addButton({'textContent': 'Unselect All'}, (instance, button) => {
|
|
button.onclick = () => selectColorList(true);
|
|
}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container bm-scrollable'})
|
|
.addDiv({'class': 'bm-container', 'style': 'margin-left: 2.5ch; margin-right: 2.5ch;'})
|
|
.addDiv({'class': 'bm-container'})
|
|
.addSpan({'id': 'bm-filter-tot-correct', 'innerHTML': '<b>Correct Pixels:</b> ???'}).buildElement()
|
|
.addBr().buildElement()
|
|
.addSpan({'id': 'bm-filter-tot-total', 'innerHTML': '<b>Total Pixels:</b> ???'}).buildElement()
|
|
.addBr().buildElement()
|
|
.addSpan({'id': 'bm-filter-tot-remaining', 'innerHTML': '<b>Complete:</b> ??? (???)'}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addP({'innerHTML': `Colors with the icon ${eyeOpen.replace('<svg', '<svg aria-label="Eye Open"')} will be shown on the canvas. Colors with the icon ${eyeClosed.replace('<svg', '<svg aria-label="Eye Closed"')} will not be shown on the canvas. The "Select All" and "Unselect All" buttons only apply to colors that display in the list below. The amount of correct pixels is dependent on how much of the template you have loaded since you opened Wplace.live.`}).buildElement()
|
|
.buildElement()
|
|
.addHr().buildElement()
|
|
.addForm({'class': 'bm-container'})
|
|
.addFieldset()
|
|
.addLegend({'textContent': 'Sort Options:', 'style': 'font-weight: 700;'}).buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addSelect({'id': 'bm-filter-sort-primary', 'name': 'sortPrimary', 'textContent': 'I want to view '})
|
|
.addOption({'value': 'id', 'textContent': 'color IDs'}).buildElement()
|
|
.addOption({'value': 'name', 'textContent': 'color names'}).buildElement()
|
|
.addOption({'value': 'premium', 'textContent': 'premium colors'}).buildElement()
|
|
.addOption({'value': 'percent', 'textContent': 'percentage'}).buildElement()
|
|
.addOption({'value': 'correct', 'textContent': 'correct pixels'}).buildElement()
|
|
.addOption({'value': 'incorrect', 'textContent': 'incorrect pixels'}).buildElement()
|
|
.addOption({'value': 'total', 'textContent': 'total pixels'}).buildElement()
|
|
.buildElement()
|
|
.addSelect({'id': 'bm-filter-sort-secondary', 'name': 'sortSecondary', 'textContent': ' in '})
|
|
.addOption({'value': 'ascending', 'textContent': 'ascending'}).buildElement()
|
|
.addOption({'value': 'descending', 'textContent': 'descending'}).buildElement()
|
|
.buildElement()
|
|
.addSpan({'textContent': ' order.'}).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addCheckbox({'id': 'bm-filter-show-unused', 'name': 'showUnused', 'textContent': 'Show unused colors'}).buildElement()
|
|
.buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-container'})
|
|
.addButton({'textContent': 'Sort Colors', 'type': 'submit'}, (instance, button) => {
|
|
button.onclick = (event) => {
|
|
event.preventDefault(); // Stop default form submission
|
|
|
|
// Get the form data
|
|
const formData = new FormData(document.querySelector('#bm-window-filter form'));
|
|
const formValues = {};
|
|
for ([input, value] of formData) {
|
|
formValues[input] = value;
|
|
}
|
|
console.log(`Primary: ${formValues['sortPrimary']}; Secondary: ${formValues['sortSecondary']}; Unused: ${formValues['showUnused'] == 'on'}`);
|
|
|
|
// Sort the color list
|
|
sortColorList(formValues['sortPrimary'], formValues['sortSecondary'], formValues['showUnused'] == 'on');
|
|
}
|
|
}).buildElement()
|
|
.buildElement()
|
|
.buildElement()
|
|
.buildElement()
|
|
// Color list will appear here in the DOM tree
|
|
.buildElement()
|
|
.buildElement()
|
|
.buildElement().buildOverlay(document.body);
|
|
|
|
// Creates dragging capability on the drag bar for dragging the window
|
|
overlayFilter.handleDrag('#bm-window-filter.bm-window', '#bm-window-filter .bm-dragbar');
|
|
|
|
// Obtains the scrollable container to put the color filter in
|
|
const scrollableContainer = document.querySelector('#bm-window-filter .bm-container.bm-scrollable');
|
|
|
|
// Obtains the palette Blue Marble currently uses
|
|
const { palette: palette, LUT: _ } = templateManager.paletteBM;
|
|
|
|
// Pixel totals
|
|
let allPixelsTotal = 0;
|
|
let allPixelsCorrectTotal = 0;
|
|
const allPixelsCorrect = new Map();
|
|
const allPixelsColor = new Map();
|
|
|
|
// Sum the pixel totals across all templates.
|
|
// If there is no total for a template, it defaults to zero
|
|
for (const template of templateManager.templatesArray) {
|
|
|
|
const total = template.pixelCount?.total ?? 0;
|
|
allPixelsTotal += total ?? 0; // Sums the pixels placed as "total" per everything
|
|
|
|
const colors = template.pixelCount?.colors ?? new Map();
|
|
|
|
// Sums the color pixels placed as "total" per color ID
|
|
for (const [colorID, colorPixels] of colors) {
|
|
const _colorPixels = Number(colorPixels) || 0; // Boilerplate
|
|
const allPixelsColorSoFar = allPixelsColor.get(colorID) ?? 0; // The total color pixels for this color ID so far, or zero if none counted so far
|
|
allPixelsColor.set(colorID, allPixelsColorSoFar + _colorPixels);
|
|
}
|
|
|
|
// Object that contains the tiles which contain Maps as correct pixels per tile as the value in the key-value pair
|
|
const correctObject = template.pixelCount?.correct ?? {};
|
|
|
|
// Sums the pixels placed as "correct" per color ID
|
|
for (const map of Object.values(correctObject)) { // Per tile per template
|
|
for (const [colorID, correctPixels] of map) { // Per color per tile per template
|
|
const _correctPixels = Number(correctPixels) || 0; // Boilerplate
|
|
allPixelsCorrectTotal += _correctPixels; // Sums the pixels placed as "correct" per everything
|
|
const allPixelsCorrectSoFar = allPixelsCorrect.get(colorID) ?? 0; // The total correct pixels for this color ID so far, or zero if none counted so far
|
|
allPixelsCorrect.set(colorID, allPixelsCorrectSoFar + _correctPixels);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Displays the total amounts across all colors to the user
|
|
overlayFilter.updateInnerHTML('#bm-filter-tot-correct', `<b>Correct Pixels:</b> ${localizeNumber.format(allPixelsCorrectTotal)}`);
|
|
overlayFilter.updateInnerHTML('#bm-filter-tot-total', `<b>Total Pixels:</b> ${localizeNumber.format(allPixelsTotal)}`);
|
|
overlayFilter.updateInnerHTML('#bm-filter-tot-remaining', `<b>Remaining:</b> ${localizeNumber.format((allPixelsTotal || 0) - (allPixelsCorrectTotal || 0))} (${localizePercent.format(((allPixelsTotal || 0) - (allPixelsCorrectTotal || 0)) / (allPixelsTotal || 1))})`);
|
|
|
|
// These run when the user opens the Color Filter window
|
|
buildColorList();
|
|
sortColorList('id', 'ascending', false);
|
|
|
|
// Creates the color list container
|
|
function buildColorList() {
|
|
|
|
const colorList = new Overlay(name, version);
|
|
colorList.addDiv({'class': 'bm-filter-flex'})
|
|
// We leave it open so we can add children to the grid
|
|
|
|
// For each color in the palette...
|
|
for (const color of palette) {
|
|
|
|
// Relative Luminance
|
|
const lumin = calculateRelativeLuminance(color.rgb);
|
|
|
|
// Calculates if white or black text would contrast better with the palette color
|
|
const textColorForPaletteColorBackground =
|
|
(((1.05) / (lumin + 0.05)) > ((lumin + 0.05) / 0.05))
|
|
? 'white' : 'black';
|
|
|
|
const bgEffectForButtons = (textColorForPaletteColorBackground == 'white') ? 'bm-button-hover-white' : 'bm-button-hover-black';
|
|
|
|
// Turns "total" color into a string of a number; "0" if unknown
|
|
const colorTotal = allPixelsColor.get(color.id) ?? 0
|
|
const colorTotalLocalized = localizeNumber.format(colorTotal);
|
|
|
|
// This will be displayed if the total pixels for this color is zero
|
|
let colorCorrect = 0;
|
|
let colorCorrectLocalized = '0';
|
|
let colorPercent = localizePercent.format(1);
|
|
|
|
// This will be displayed if the total pixels for this color is non-zero
|
|
if (colorTotal != 0) {
|
|
colorCorrect = allPixelsCorrect.get(color.id) ?? '???';
|
|
colorCorrectLocalized = (typeof colorCorrect == 'string') ? colorCorrect : localizeNumber.format(colorCorrect);
|
|
colorPercent = isNaN(colorCorrect / colorTotal) ? '???' : localizePercent.format(colorCorrect / colorTotal);
|
|
}
|
|
|
|
// Incorrect pixels for this color
|
|
const colorIncorrect = parseInt(colorTotal) - parseInt(colorCorrect);
|
|
|
|
const isColorHidden = !!(templateManager.shouldFilterColor.get(color.id) || false);
|
|
|
|
// Construct the DOM tree for color in color list
|
|
colorList.addDiv({'class': 'bm-container bm-filter-color bm-flex-between',
|
|
'data-id': color.id,
|
|
'data-name': color.name,
|
|
'data-premium': +color.premium,
|
|
'data-correct': !Number.isNaN(parseInt(colorCorrect)) ? colorCorrect : '0',
|
|
'data-total': colorTotal,
|
|
'data-percent': (colorPercent.slice(-1) == '%') ? colorPercent.slice(0, -1) : '0',
|
|
'data-incorrect': colorIncorrect || 0
|
|
}).addDiv({'class': 'bm-filter-container-rgb', 'style': `background-color: rgb(${color.rgb?.map(channel => Number(channel) || 0).join(',')});`})
|
|
.addButton({
|
|
'class': 'bm-button-trans ' + bgEffectForButtons,
|
|
'data-state': isColorHidden ? 'hidden' : 'shown',
|
|
'aria-label': isColorHidden ? `Show the color ${color.name || ''} on templates.` : `Hide the color ${color.name || ''} on templates.`,
|
|
'innerHTML': isColorHidden ? eyeClosed.replace('<svg', `<svg fill="${textColorForPaletteColorBackground}"`) : eyeOpen.replace('<svg', `<svg fill="${textColorForPaletteColorBackground}"`)},
|
|
(instance, button) => {
|
|
button.onclick = () => {
|
|
button.style.textDecoration = 'none';
|
|
button.disabled = true;
|
|
if (button.dataset['state'] == 'shown') {
|
|
button.innerHTML = eyeClosed.replace('<svg', `<svg fill="${textColorForPaletteColorBackground}"`);
|
|
button.dataset['state'] = 'hidden';
|
|
button.ariaLabel = `Show the color ${color.name || ''} on templates.`;
|
|
templateManager.shouldFilterColor.set(color.id, true);
|
|
} else {
|
|
button.innerHTML = eyeOpen.replace('<svg', `<svg fill="${textColorForPaletteColorBackground}"`);
|
|
button.dataset['state'] = 'shown';
|
|
button.ariaLabel = `Hide the color ${color.name || ''} on templates.`;
|
|
templateManager.shouldFilterColor.delete(color.id);
|
|
}
|
|
button.disabled = false;
|
|
button.style.textDecoration = '';
|
|
}
|
|
}
|
|
).buildElement()
|
|
.buildElement()
|
|
.addDiv({'class': 'bm-flex-between'})
|
|
.addHeader(2, {'textContent': (color.premium ? '★ ' : '') + color.name}).buildElement()
|
|
.addDiv({'class': 'bm-flex-between', 'style': 'gap: 1.5ch;'})
|
|
.addSmall({'textContent': `#${color.id}`}).buildElement()
|
|
.addSmall({'textContent': `${colorCorrectLocalized} / ${colorTotalLocalized}`}).buildElement()
|
|
.buildElement()
|
|
.addP({'textContent': `${((typeof colorIncorrect == 'number') && !isNaN(colorIncorrect)) ? colorIncorrect : '???'} incorrect pixels. Completed: ${colorPercent}`}).buildElement()
|
|
.buildElement()
|
|
.buildElement()
|
|
}
|
|
|
|
// Adds the colors to the color container in the filter window
|
|
colorList.buildOverlay(scrollableContainer);
|
|
}
|
|
|
|
// Sorts the color list & hides unused colors
|
|
function sortColorList(sortPrimary, sortSecondary, showUnused) {
|
|
// "sortSecondary" can be either 'ascending' or 'descending'
|
|
|
|
const colorList = document.querySelector('.bm-filter-flex');
|
|
|
|
const colors = Array.from(colorList.children);
|
|
|
|
colors.sort((index, nextIndex) => {
|
|
const indexValue = index.getAttribute('data-' + sortPrimary);
|
|
const nextIndexValue = nextIndex.getAttribute('data-' + sortPrimary);
|
|
|
|
const indexValueNumber = parseFloat(indexValue);
|
|
const nextIndexValueNumber = parseFloat(nextIndexValue);
|
|
|
|
const indexValueNumberIsNumber = !isNaN(indexValueNumber);
|
|
const nextIndexValueNumberIsNumber = !isNaN(nextIndexValueNumber);
|
|
|
|
// If the user wants to show unused colors...
|
|
if (showUnused) {
|
|
index.classList.remove('bm-color-hide'); // Show the color
|
|
} else if (!Number(index.getAttribute('data-total'))) {
|
|
// ...else if the user wants to hide unused colors, and this color is unused...
|
|
|
|
index.classList.add('bm-color-hide'); // Hide the color
|
|
}
|
|
|
|
// If both index values are numbers...
|
|
if (indexValueNumberIsNumber && nextIndexValueNumberIsNumber) {
|
|
// Perform numeric comparison
|
|
return sortSecondary === 'ascending' ? indexValueNumber - nextIndexValueNumber : nextIndexValueNumber - indexValueNumber;
|
|
} else {
|
|
// Otherwise, perform string comparison
|
|
const indexValueString = indexValue.toLowerCase();
|
|
const nextIndexValueString = nextIndexValue.toLowerCase();
|
|
if (indexValueString < nextIndexValueString) return sortSecondary === 'ascending' ? -1 : 1;
|
|
if (indexValueString > nextIndexValueString) return sortSecondary === 'ascending' ? 1 : -1;
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
colors.forEach(color => colorList.appendChild(color));
|
|
}
|
|
|
|
// (Un)selects all colors in the color list that are visible to the user
|
|
function selectColorList(userWantsUnselect) {
|
|
|
|
// Gets the colors
|
|
const colorList = document.querySelector('.bm-filter-flex');
|
|
const colors = Array.from(colorList.children);
|
|
|
|
// For each color...
|
|
for (const color of colors) {
|
|
|
|
// Skip this color if it is hidden
|
|
if (color.classList?.contains('bm-color-hide')) {continue;}
|
|
|
|
// Gets the button to click
|
|
const button = color.querySelector('.bm-filter-container-rgb button');
|
|
|
|
// Exits early if the button is in its proper state
|
|
if ((button.dataset['state'] == 'hidden') && !userWantsUnselect) {continue;} // If the button is selected, and the user wants to select all buttons, then skip this one
|
|
if ((button.dataset['state'] == 'shown') && userWantsUnselect) {continue;} // If the button is not selected, and the user wants to unselect all buttons, then skip this one
|
|
|
|
button.click(); // If the button is not in its proper state, then we click it
|
|
}
|
|
}
|
|
}
|