// ==UserScript==
// @name Wplace chunk downloader
// @namespace http://tampermonkey.net/
// @version 2.4.1
// @description Easily download chunk images from wplace.live using multi-point selection and highlighting
// @author NotNotWaldo
// @match https://wplace.live/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wplace.live
// @license MIT
// @grant GM_setValue
// @grant GM_getValue
// @grant GM.setValue
// @grant GM.getValue
// @run-at document-end
// @grant none
// ==/UserScript==
// Code below, feel free to read in horror
(() => {
// Global vals because I'm way too lazy
// These variables are for handling the highlighting feature
let isHightlightOn = false;
let downloadingState = false; // the downloading state ensures that the highlight wont be also printed when downloading the images of chunk
let highlightedChunksLinksArr = []; // array for the highlighted chunks
// the coords of chunks that are selected by the points you've set
let mlChunkCoords = {
firstChunk: { x: null, y: null },
secondChunk: { x: null, y: null },
};
let mlPixelCoords = {
firstPixel: { x: null, y: null },
secondPixel: { x: null, y: null },
};
// variables for the currently selected chunks
let chunkX = null;
let chunkY = null;
let pixelX = null;
let pixelY = null;
let chunkUrl = null;
// for the dragging mechanic
let isPointing = false;
// just a template for chunk img
const chunkTemplateUrl = `https://backend.wplace.live/files/s0/tiles/`;
// for the amount of downloading instances
let concurrentDlInstances = 3; // yea, dont modify this tho
// you can modify this here if you want to increase the max instances
let maxDlInstances = 30; // dont go lower than 1. Honestly, why would you?
// variables for the download bar
let currImgsDownloaded = null;
let totalImgsToBeDownloaded = null;
// for preset deletion confirmation
let dontAskPresetDelete = false;
// for lazily waiting for something
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// for getting and setting persistent values
let getGMValue = (key, def) => {
try {
if (typeof GM !== "undefined" && typeof GM.getValue === "function")
return GM.getValue(key, def);
if (typeof GM_getValue === "function")
return Promise.resolve(GM_getValue(key, def));
} catch {}
// Fallback: localStorage
try {
let val = localStorage.getItem("gm_" + key);
return Promise.resolve(val !== null ? JSON.parse(val) : def);
} catch {
return Promise.resolve(def);
}
};
let setGMValue = (key, value) => {
try {
if (typeof GM !== "undefined" && typeof GM.setValue === "function")
return GM.setValue(key, value);
if (typeof GM_setValue === "function")
return Promise.resolve(GM_setValue(key, value));
} catch {}
// Fallback: localStorage
try {
localStorage.setItem("gm_" + key, JSON.stringify(value));
} catch {}
return Promise.resolve();
};
let savedConfigs = {};
let initConfig = async () => {
savedConfigs = {
concurrentDlInstances,
dontAskPresetDelete,
...((await getGMValue("savedConfigs")) || {}),
};
if (Object.keys(savedConfigs).length === 0) {
savedConfigs.concurrentDlInstances = concurrentDlInstances;
savedConfigs.dontAskPresetDelete = dontAskPresetDelete;
setGMValue("savedConfigs", savedConfigs);
} else {
({ concurrentDlInstances, dontAskPresetDelete } = savedConfigs);
}
let instanceInfo = miscSection.querySelector(".instanceInfo");
instanceInfo.textContent = `Download instances: ${concurrentDlInstances}`;
};
initConfig();
let multipleChunksDownloaderElem = document.createElement("div");
multipleChunksDownloaderElem.className = "mulChunksDownloader";
multipleChunksDownloaderElem.innerHTML = `
<div class="chunk-downloader">
<!-- Multiple Chunk Downloader -->
<div class="mainHead section-header">
<span>Wplace Chunks Downloader</span>
<button class="simple-btn">–</button>
</div>
<div class="mainCollapsible expanded">
<div class="infoSection section">
<div class="section-header coords">
<span class="chunkSelectedInfo">Chunk selected: X: null, Y: null</span>
</div>
<div class="btn-row">
<button class="downloadChunkBtn btn btn-primary">Download Chunk</button>
<button class="viewChunkBtn btn">View Chunk Image</button>
</div>
</div>
<div class="mulChunkSection section">
<div class="section-header">
<span>Multiple Chunks Downloader</span>
<button class="simple-btn">+</button>
</div>
<div class="mulChunksSectionsCon collapsible collapsed">
<div class="section">
<div class="chunksInfo">
<div class="coords">
<span>1st X: null, Y: null</span>
<span>2nd X: null, Y: null</span>
</div>
<div>
<span class="chunkAmountText">Current amount of chunks: 0</span>
</div>
</div>
<div class="btn-row">
<button class="firstPointBtn btn btn-primary">Set 1st Point</button>
<button class="secPointBtn btn btn-primary">Set 2nd Point</button>
</div>
<div class="btn-row">
<button class="downloadBtn btn btn-primary">Download Chunks</button>
<button class="removePointBtn btn">Remove Points</button>
</div>
</div>
<div class="regionDownloadSection section">
<div class="section-header">
<span>Pixel Region Downloader</span>
<button class="simple-btn">+</button>
</div>
<div class="collapsible collapsed">
<div class="pixelCoords">
<span>1st X: null, Y: null</span>
<span>2nd X: null, Y: null</span>
</div>
<div class="btn-col">
<button class="downloadRegionBtn btn btn-primary">Download Region</button>
</div>
</div>
</div>
<div class="miscSection section">
<div class="section-header">
<span>Misc</span>
<button class="simple-btn">+</button>
</div>
<div class="collapsible collapsed">
<div class="multipleInstance">
<span class="instanceInfo">Download instances: ${concurrentDlInstances}</span>
<div class="instanceIncDecBtns">
<button class="instanceInc simple-btn">+</button>
<button class="instanceDec simple-btn">–</button>
</div>
</div>
<div class="btn-col">
<button class="highlightBtn btn">Highlight Chunks</button>
</div>
</div>
</div>
</div>
</div>
<div class="savesSection section">
<div class="section-header">
<span>Presets</span>
<button class="simple-btn">+</button>
</div>
<div class="collapsible collapsed">
<div class="saveCurrPreset">
<input class="presetNameInput input-box" type="text" name="coordsName" placeholder="Preset name"></input>
<div class="btn-col">
<button class="savePresetBtn btn-primary btn">Save Current Points as Preset</button>
</div>
</div>
<div class="savedPresets">
</div>
</div>
</div>
<!-- Manual Chunk Download -->
<div class="manualChunkSection section">
<div class="section-header">
<span>Manual Chunk Downloader</span>
<button class="simple-btn">+</button>
</div>
<div class="collapsible collapsed">
<input class="coordsInput input-box" type="text" name="chunksCoords" placeholder="firstX, firstY, secX, secY, safety">
</input>
<div class="btn-row" style="grid-template-columns: 1fr;">
<button class="manualDownloadBtn btn btn-primary">Download</button>
</div>
</div>
</div>
<div class="downloadBarCon">
<div class="download-bar">
<div class="download-progress"></div>
<span class="download-text">0 / 0</span>
</div>
</div>
</div>
</div>
`;
let style = document.createElement("style");
style.textContent = `
/* ================================
Container & Layout
================================ */
.mulChunksDownloader {
position: fixed;
bottom: 12px;
left: 12px;
top: auto;
z-index: 49;
}
.mulChunksDownloader .chunk-downloader {
width: 360px;
padding: 16px;
font-family: sans-serif;
font-size: 14px;
color: #111827;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* ================================
Sections
================================ */
.mulChunksDownloader .section {
margin-bottom: 16px;
}
.mulChunksDownloader .mainHead {
margin: 0;
cursor: move;
}
.mulChunksDownloader .infoSection {
margin-top: 16px;
}
.mulChunksDownloader .mulChunkSection .mulChunksSectionsCon {
display: flex;
flex-direction: column;
gap: 10px;
}
.mulChunksDownloader .mulChunkSection .mulChunksSectionsCon .section {
margin: 0;
}
/* ================================
Headers
================================ */
.mulChunksDownloader .section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3px;
font-weight: 600;
}
.mulChunksDownloader .savesSection .preset-header {
padding: 10px 7px;
font-weight: 600;
cursor: pointer;
}
/* ================================
Collapsibles
================================ */
.mulChunksDownloader .mainCollapsible,
.mulChunksDownloader .collapsible {
overflow: hidden;
transition: max-height 0.3s ease;
}
.mulChunksDownloader .mainCollapsible.collapsed,
.mulChunksDownloader .collapsible.collapsed {
max-height: 0;
}
.mulChunksDownloader .mainCollapsible.expanded {
max-height: 2000px; /* large enough to fit all content */
}
.mulChunksDownloader .collapsible.expanded {
max-height: 1000px;
}
/* ================================
Text & Info
================================ */
.mulChunksDownloader .coords {
width: 100%;
display: flex;
justify-content: space-around;
align-items: center;
gap: 8px;
font-size: 14px;
}
.mulChunksDownloader .pixelCoords {
display: flex;
justify-content: space-around;
align-items: center;
gap: 8px;
padding: 4px 12px;
font-size: 14px;
border-radius: 9999px;
background: #f3f4f6;
margin-top: 10px;
}
.mulChunksDownloader .chunksInfo {
margin: 5px 0;
padding: 6px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-radius: 10px;
background: #e8e8e9;
}
.mulChunksDownloader .chunkAmountText {
font-weight: 600;
}
/* ================================
Buttons
================================ */
.mulChunksDownloader .btn {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 9999px;
background: #f3f4f6;
color: #374151;
font-size: 13px;
text-align: center;
cursor: pointer;
}
.mulChunksDownloader .btn:hover {
background: #e5e7eb;
}
.mulChunksDownloader .btn-primary {
background: #2563eb;
border: none;
color: #fff;
}
.mulChunksDownloader .btn-primary:hover {
background: #1d4ed8;
}
.simple-btn {
width: 24px;
height: 24px;
font-size: 14px;
color: #4b5563;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
}
.simple-btn:hover {
background: #e5e7eb;
}
.btn-primary:hover {
background: #1d4ed8;
}
.mulChunksDownloader .del-btn {
background: #fff;
border: 2px solid #eb2525;
color: #eb2525;
}
.mulChunksDownloader .del-btn:hover {
background: #c02828;
color: #fff;
}
.mulChunksDownloader button:disabled {
background-color: #4b5563;
color: #9ca3af;
cursor: not-allowed;
opacity: 0.7;
}
/* ================================
Button Layout
================================ */
.mulChunksDownloader .btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.mulChunksDownloader .btn-col {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
/* ================================
Inputs
================================ */
.mulChunksDownloader .input-box {
width: 100%;
padding: 6px 12px;
margin-top: 8px;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 9999px;
background: #f3f4f6;
outline: none;
transition: border 0.2s, box-shadow 0.2s;
}
.mulChunksDownloader .input-box:focus {
border-color: #2563eb;
background: #fff;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* ================================
Presets
================================ */
.mulChunksDownloader .savesSection .preset {
border-radius: 5px;
transition: 0.2s ease;
}
.mulChunksDownloader .savesSection .preset:hover {
background: #eeeff1;
}
.mulChunksDownloader .savesSection .savedPresets {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 10px;
}
.mulChunksDownloader .savesSection .preset .preset-header {
border-bottom: 1px solid #d1d5db;
display: flex;
justify-content: space-between;
}
.mulChunksDownloader .savesSection .preset .preset-info {
margin-top: 5px;
display: flex;
flex-direction: column;
gap: 5px;
}
.mulChunksDownloader .savesSection .preset .point-info {
display: flex;
flex-direction: row;
justify-content: space-around;
}
.mulChunksDownloader .savesSection .preset .point-num {
font-weight: 600;
}
.mulChunksDownloader .savedPresets {
max-height: 200px;
overflow-y: auto;
padding-right: 4px;
}
/* Optional: style the scrollbar for better look */
.mulChunksDownloader .savedPresets::-webkit-scrollbar {
width: 6px;
}
.mulChunksDownloader .savedPresets::-webkit-scrollbar-thumb {
background: #9ca3af; /* gray thumb */
border-radius: 3px;
}
.mulChunksDownloader .savedPresets::-webkit-scrollbar-thumb:hover {
background: #6b7280; /* darker on hover */
}
.mulChunksDownloader .savedPresets::-webkit-scrollbar-track {
background: transparent;
}
/* ================================
Download Bar
================================ */
.mulChunksDownloader .download-bar {
position: relative;
width: 100%;
height: 24px;
margin-top: 10px;
background-color: #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.mulChunksDownloader .download-progress {
width: 0%;
height: 100%;
background-color: #007bff;
transition: width 0.3s ease;
}
.mulChunksDownloader .download-text {
position: absolute;
top: 0;
left: 50%;
font-size: 12px;
font-weight: bold;
color: #fff;
line-height: 24px;
transform: translateX(-50%);
}
/* ================================
Misc Section
================================ */
.mulChunksDownloader .multipleInstance {
margin-top: 10px;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
.mulChunksDownloader .instanceIncDecBtns {
display: flex;
gap: 10px;
}
/* ================================
Preset Deletion Section
================================ */
.preset-delete-modal .simple-btn {
width: auto;
padding: 5px 7px;
}
.preset-delete-modal .del-btn
{
background: #fff;
border: 2px solid #eb2525;
color: #eb2525;
}
.preset-delete-modal .del-btn:hover {
background: #c02828;
color: #fff;
}
`;
document.head.appendChild(style);
document.body.appendChild(multipleChunksDownloaderElem);
// === COLLAPSING MECHANISM SECTION ===
let collapseSection = (btn, target) => {
let isCollapsed = btn.dataset.collapsed === "true";
if (!isCollapsed) {
target.classList.remove("expanded");
target.classList.add("collapsed");
btn.dataset.collapsed = "true";
btn.textContent = "+";
} else {
target.classList.remove("collapsed");
target.classList.add("expanded");
btn.dataset.collapsed = "false";
btn.textContent = "–";
}
};
// Main collapse
let mainHead = multipleChunksDownloaderElem.querySelector(".mainHead");
const mainCollapsible =
multipleChunksDownloaderElem.querySelector(".mainCollapsible");
const mainCollapseBtn = multipleChunksDownloaderElem.querySelector(
".mainHead .simple-btn"
);
mainCollapseBtn.dataset.collapsed = "false";
mainCollapseBtn.addEventListener("click", () => {
collapseSection(mainCollapseBtn, mainCollapsible);
});
// Multiple Chunks Section collapse
let mulChunkSection =
multipleChunksDownloaderElem.querySelector(".mulChunkSection");
let mulChunkCollapseBtn = mulChunkSection.querySelector(".simple-btn");
let mulChunkCollapsible = mulChunkSection.querySelector(".collapsible");
mulChunkCollapseBtn.dataset.collapsed = "true";
mulChunkCollapseBtn.addEventListener("click", () => {
collapseSection(mulChunkCollapseBtn, mulChunkCollapsible);
});
// Misc section collapse
let miscSection = mulChunkSection.querySelector(".miscSection");
let miscCollapseBtn = miscSection.querySelector(".simple-btn");
let miscCollapsible = miscSection.querySelector(".collapsible");
miscCollapseBtn.dataset.collapsed = "true";
miscCollapseBtn.addEventListener("click", () => {
collapseSection(miscCollapseBtn, miscCollapsible);
});
// Manual Chunks Section collapse
let manualChunkSection = multipleChunksDownloaderElem.querySelector(
".manualChunkSection"
);
let manualCollapseBtn = manualChunkSection.querySelector(".simple-btn");
let manualCollapsible = manualChunkSection.querySelector(".collapsible");
manualCollapseBtn.dataset.collapsed = "true"; // starts collapsed
manualCollapseBtn.addEventListener("click", () => {
collapseSection(manualCollapseBtn, manualCollapsible);
});
let regionDownloadSection = mulChunkSection.querySelector(
".regionDownloadSection"
);
let rgDlSectionCollapseBtn =
regionDownloadSection.querySelector(".simple-btn");
let rgDlSectionCollapsible =
regionDownloadSection.querySelector(".collapsible");
rgDlSectionCollapseBtn.dataset.collapsed = "true";
rgDlSectionCollapseBtn.addEventListener("click", () => {
collapseSection(rgDlSectionCollapseBtn, rgDlSectionCollapsible);
});
let savesSection =
multipleChunksDownloaderElem.querySelector(".savesSection");
let savesSectionCollapsible = savesSection.querySelector(".collapsible");
let savesSectionCollapseBtn = savesSection.querySelector(".simple-btn");
savesSectionCollapseBtn.dataset.collapsed = "true";
savesSectionCollapseBtn.addEventListener("click", () => {
collapseSection(savesSectionCollapseBtn, savesSectionCollapsible);
});
// === COLLAPSING MECHANISM SECTION END ===
// for collapsing preset infos
let collapsePreset = (header, target) => {
let isCollapsed = header.dataset.collapsed === "true";
if (!isCollapsed) {
target.classList.remove("expanded");
target.classList.add("collapsed");
header.dataset.collapsed = "true";
} else {
target.classList.remove("collapsed");
target.classList.add("expanded");
header.dataset.collapsed = "false";
}
};
let savedPresetsCon = savesSection.querySelector(".savedPresets");
let presetIds = new Set();
let savedPresets = {};
// for creating preset element
let createPresetElem = (presetId, pointsData, onDelete) => {
let { firstPoint, secondPoint, name } = pointsData;
if (!firstPoint || !secondPoint) return null;
let preset = document.createElement("div");
preset.className = "preset";
preset.dataset.presetID = presetId;
let presetHeader = document.createElement("div");
presetHeader.className = "preset-header";
presetHeader.dataset.collapsed = "true";
let title = document.createElement("span");
title.textContent = name;
let deleteBtn = document.createElement("button");
deleteBtn.className = "deletePresetBtn simple-btn del-btn";
deleteBtn.textContent = "X";
presetHeader.append(title, deleteBtn);
let presetCollapsible = document.createElement("div");
presetCollapsible.className = "collapsible collapsed";
let info = document.createElement("div");
info.className = "preset-info";
info.innerHTML = `
<div>
<span class="point-num">First point</span>
<div class="point-info">
<span>Chunk: ${firstPoint.chunk.x}, ${firstPoint.chunk.y}</span>
<span>Pixel: ${firstPoint.pixel.x}, ${firstPoint.pixel.y}</span>
</div>
</div>
<div>
<span class="point-num">Second point</span>
<div class="point-info">
<span>Chunk: ${secondPoint.chunk.x}, ${secondPoint.chunk.y}</span>
<span>Pixel: ${secondPoint.pixel.x}, ${secondPoint.pixel.y}</span>
</div>
</div>
`;
let presetBtnsCon = document.createElement("div");
presetBtnsCon.className =
firstPoint.pixel.x == null || secondPoint.pixel.x == null
? "preset-btns btn-col"
: "preset-btns btn-row";
let presetDlChunksBtn = document.createElement("button");
presetDlChunksBtn.type = "button";
presetDlChunksBtn.className = "presetDlChunksBtn dlBtn btn-primary btn";
presetDlChunksBtn.textContent = "Download Chunks";
presetBtnsCon.appendChild(presetDlChunksBtn);
if (presetBtnsCon.classList.contains("btn-row")) {
let presetDlRegionBtn = document.createElement("button");
presetDlRegionBtn.type = "button";
presetDlRegionBtn.className = "presetDlRegionBtn dlBtn btn-primary btn";
presetDlRegionBtn.textContent = "Download Region";
presetBtnsCon.appendChild(presetDlRegionBtn);
presetDlRegionBtn.addEventListener("click", () => {
regionDl?.(
{ firstChunk: firstPoint.chunk, secondChunk: secondPoint.chunk },
{ firstPixel: firstPoint.pixel, secondPixel: secondPoint.pixel },
name,
false
);
});
}
presetCollapsible.append(info, presetBtnsCon);
preset.append(presetHeader, presetCollapsible);
presetHeader.addEventListener("click", () => {
collapsePreset(presetHeader, presetCollapsible);
});
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
onDelete?.(presetId, preset);
});
presetDlChunksBtn.addEventListener("click", () => {
multipleChunksDlUrl?.(
{ firstChunk: firstPoint.chunk, secondChunk: secondPoint.chunk },
name,
false
);
});
return preset;
};
// For initalizing presets
let initPresets = async () => {
savedPresets = (await getGMValue("savedPresets")) || {};
presetIds = new Set(Object.keys(savedPresets));
Object.entries(savedPresets).forEach(([id, data]) => {
let presetElem = createPresetElem(id, data, deletePreset);
savedPresetsCon.appendChild(presetElem);
});
};
initPresets();
// For deleting preset
function showDeleteConfirm(onConfirm) {
// If user disabled confirmation, just run it
if (dontAskPresetDelete) {
onConfirm();
return;
}
// Creates a modal container
const modal = document.createElement("div");
modal.className = "preset-delete-modal";
modal.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex; justify-content: center; align-items: center;
z-index: 9999;
`;
// Inner box
const box = document.createElement("div");
box.style.cssText = `
background: #fff; padding: 16px; border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 300px;
text-align: center;
`;
box.innerHTML = `
<p>Are you sure you want to delete this preset?</p>
<label style="display:flex;align-items:center;justify-content:center;margin:8px 0;gap:6px;">
<input type="checkbox" id="dontAskCheckbox">
<span>Don’t ask again</span>
</label>
<div style="margin-top:12px; display:flex; gap:10px; justify-content:center;">
<button id="confirmDeleteBtn" class="simple-btn del-btn">Delete</button>
<button id="cancelDeleteBtn" class="simple-btn">Cancel</button>
</div>
`;
modal.appendChild(box);
document.body.appendChild(modal);
// Handlers
box.querySelector("#confirmDeleteBtn").onclick = () => {
const dontAsk = box.querySelector("#dontAskCheckbox").checked;
if (dontAsk) dontAskPresetDelete = true;
onConfirm();
modal.remove();
};
box.querySelector("#cancelDeleteBtn").onclick = () => modal.remove();
}
let deletePreset = (presetId, elem) => {
showDeleteConfirm(() => {
savedConfigs.dontAskPresetDelete = dontAskPresetDelete;
setGMValue("savedConfigs", savedConfigs);
delete savedPresets[presetId];
presetIds.delete(presetId);
elem.remove();
setGMValue("savedPresets", savedPresets);
});
};
function generateId(length = 8) {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let id;
do {
id = Array.from(
{ length },
() => chars[Math.floor(Math.random() * chars.length)]
).join("");
} while (presetIds.has(id)); // regenerate if duplicate
presetIds.add(id);
return id;
}
function createBlankPreset(name = "Untitled Preset") {
return {
name,
firstPoint: {
chunk: { x: null, y: null },
pixel: { x: null, y: null },
},
secondPoint: {
chunk: { x: null, y: null },
pixel: { x: null, y: null },
},
};
}
// for the dragging mechanism
let isDragging = false;
let offsetX = 0;
let offsetY = 0;
mainHead.addEventListener("mousedown", (e) => {
isDragging = true;
// Calculates click offset inside the box
const rect = multipleChunksDownloaderElem.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
// Prevent accidental text selection
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
multipleChunksDownloaderElem.style.top = `${e.clientY - offsetY}px`;
multipleChunksDownloaderElem.style.left = `${e.clientX - offsetX}px`;
multipleChunksDownloaderElem.style.bottom = "auto"; // stop sticking to bottom
multipleChunksDownloaderElem.style.right = "auto"; // stop sticking to left
multipleChunksDownloaderElem.style.position = "fixed";
});
document.addEventListener("mouseup", () => {
isDragging = false;
});
// for displaying info about points and currently selected chunk
let infoSection = multipleChunksDownloaderElem.querySelector(".infoSection");
let downloadChunkBtn = infoSection.querySelector(".downloadChunkBtn");
downloadChunkBtn.addEventListener("click", async () => {
if (chunkX == null) return;
multipleChunksDlUrl({
firstChunk: { x: chunkX, y: chunkY },
secondChunk: { x: chunkX, y: chunkY },
});
});
let viewChunkBtn = infoSection.querySelector(".viewChunkBtn");
viewChunkBtn.addEventListener("click", (event) => {
if (chunkX == null) return;
window.open(chunkUrl, "_blank");
});
// to update the infos displayed
const refreshSetPointsInfo = () => {
let coordsCon = mulChunkSection.querySelector(".coords");
let currentCoords = infoSection.querySelector("span");
currentCoords.textContent = `Chunk selected: X: ${chunkX}, Y: ${chunkY}`;
let infoChildren = coordsCon.querySelectorAll("span");
infoChildren[0].textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;
infoChildren[1].textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;
let chunkAmountText = mulChunkSection.querySelector(".chunkAmountText");
let currentChunkAmount = getAmountOfChunksSelected(mlChunkCoords);
chunkAmountText.textContent = `Current amount of chunks: ${currentChunkAmount}`;
let pixelCoordsCon = regionDownloadSection.querySelector(".pixelCoords");
let pixelinfoChildren = pixelCoordsCon.querySelectorAll("span");
pixelinfoChildren[0].textContent = `1st X: ${mlPixelCoords.firstPixel.x}, Y: ${mlPixelCoords.firstPixel.y}`;
pixelinfoChildren[1].textContent = `2nd X: ${mlPixelCoords.secondPixel.x}, Y: ${mlPixelCoords.secondPixel.y}`;
};
let getAmountOfChunksSelected = (chunkCoords) => {
if (chunkCoords.firstChunk.x == null && chunkCoords.secondChunk.x == null)
return 0;
if (chunkCoords.secondChunk.x == null || chunkCoords.firstChunk.x == null)
return 1;
let organizedChunkCoords = mlCoordsOrganizer(chunkCoords);
let topLeft = organizedChunkCoords.firstChunk;
let botRight = organizedChunkCoords.secondChunk;
let width = 1 + botRight.x - topLeft.x;
let height = 1 + botRight.y - topLeft.y;
return width * height;
};
// for the multiple chunk downloader elems/buttons
let firstPointBtn = mulChunkSection.querySelector(".firstPointBtn");
let secPointBtn = mulChunkSection.querySelector(".secPointBtn");
firstPointBtn.addEventListener("click", async (e) => {
await setPoint("first");
});
secPointBtn.addEventListener("click", async (e) => {
await setPoint("sec");
});
let setPoint = async (position) => {
if (chunkX == null) return;
if (position == "first") {
mlChunkCoords.firstChunk = { x: chunkX, y: chunkY };
mlPixelCoords.firstPixel = { x: pixelX, y: pixelY };
} else if (position == "sec") {
mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
mlPixelCoords.secondPixel = { x: pixelX, y: pixelY };
}
if (isHightlightOn) {
highlightedChunksLinksArr.length = 0;
let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
highlightedChunksLinksArr.push(
...getLinksFromChunkCoords(organizedCoords)
);
}
refreshSetPointsInfo();
updateButtons();
};
let removePointsBtn = mulChunkSection.querySelector(".removePointBtn");
removePointsBtn.addEventListener("click", async () => {
mlChunkCoords = {
firstChunk: { x: null, y: null },
secondChunk: { x: null, y: null },
};
mlPixelCoords = {
firstPixel: { x: null, y: null },
secondPixel: { x: null, y: null },
};
highlightedChunksLinksArr.length = 0;
isHightlightOn = false;
let highlightBtn = mulChunkSection.querySelector(".highlightBtn");
highlightBtn.textContent = "Highlight chunks";
refreshSetPointsInfo();
updateButtons();
});
// for saving coords - section
let presetNameInput = savesSection.querySelector(".presetNameInput");
const invalidChars = /[\\\/:*?"<>|]/g;
const maxLength = 40;
presetNameInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.repeat) {
addPreset();
}
});
// block typing
presetNameInput.addEventListener("keypress", (e) => {
if (invalidChars.test(e.key)) e.preventDefault();
});
// sanitize pasted text
presetNameInput.addEventListener("input", () => {
presetNameInput.value = presetNameInput.value.replace(invalidChars, "");
if (presetNameInput.value.length > maxLength) {
presetNameInput.value = presetNameInput.value.substring(0, maxLength);
}
});
let savePresetBtn = savesSection.querySelector(".savePresetBtn");
savePresetBtn.addEventListener("click", () => {
addPreset();
});
let createPreset = async () => {
let presetName = presetNameInput.value.trim() || "Untitled Preset";
let tempPreset = createBlankPreset(presetName);
let presetID = generateId(); // unique random ID
console.log("testing here");
// fill in values
tempPreset.firstPoint.chunk = { ...mlChunkCoords.firstChunk };
tempPreset.secondPoint.chunk = { ...mlChunkCoords.secondChunk };
tempPreset.firstPoint.pixel = { ...mlPixelCoords.firstPixel };
tempPreset.secondPoint.pixel = { ...mlPixelCoords.secondPixel };
savedPresets[presetID] = structuredClone(tempPreset);
await setGMValue("savedPresets", savedPresets);
return { id: presetID, ...tempPreset };
};
let addPreset = async () => {
if (
mlChunkCoords.firstChunk.x == null &&
mlChunkCoords.secondChunk.x == null
)
return;
if (presetIds.size >= 50) {
console.warn("Maximum number of presets (50) reached");
alert("You can only save up to 50 presets.");
return;
}
let newPreset = await createPreset();
let newPresetElem = await createPresetElem(
newPreset.id,
newPreset,
deletePreset
);
savedPresetsCon.appendChild(newPresetElem);
};
let highlightBtn = miscSection.querySelector(".highlightBtn");
highlightBtn.addEventListener("click", async () => {
console.log("Trying to hightlight chunks");
if (mlChunkCoords.firstChunk.x == null && mlChunkCoords.secondChunk.x)
return;
if (!isHightlightOn) {
let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
console.log(Object.keys(organizedCoords));
highlightedChunksLinksArr.push(
...getLinksFromChunkCoords(organizedCoords)
);
console.log(`Turned on hightlight`);
isHightlightOn = !isHightlightOn;
highlightBtn.textContent = "Unhighlight chunks";
} else {
highlightedChunksLinksArr.length = 0;
console.log(`Turned off highlight`);
isHightlightOn = !isHightlightOn;
highlightBtn.textContent = "Highlight chunks";
}
});
let downloadBtn = mulChunkSection.querySelector(".downloadBtn");
downloadBtn.addEventListener("click", async () => {
if (
mlChunkCoords.firstChunk.x == null &&
mlChunkCoords.secondChunk.x == null
) {
return;
}
multipleChunksDlUrl(mlChunkCoords, false);
});
// for the region download elems/buttons
let downloadRegionBtn =
regionDownloadSection.querySelector(".downloadRegionBtn");
downloadRegionBtn.addEventListener("click", (event) => {
console.log("Clicked region download");
if (
downloadingState ||
mlPixelCoords.firstPixel.x == null ||
mlPixelCoords.secondPixel.x == null
)
return;
regionDl(mlChunkCoords, mlPixelCoords, false);
});
// for the manual downloading
let coordsInput = manualChunkSection.querySelector(".coordsInput");
coordsInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.repeat) {
manualDownload();
}
});
let manualDownloadBtn =
manualChunkSection.querySelector(".manualDownloadBtn");
manualDownloadBtn.addEventListener("click", () => {
manualDownload();
});
let manualDownload = () => {
if (downloadingState) return;
let coordsText = coordsInput.value;
// Split and trim whitespace from each value
let splitUpVal = coordsText.split(",").map((v) => v.trim());
// Helper: convert string to boolean or null if invalid
const toBoolean = (str) => {
if (str.toLowerCase() === "true") return true;
if (str.toLowerCase() === "false") return false;
return null;
};
if (splitUpVal.length !== 4 && splitUpVal.length !== 5) {
console.log("You must input 4 or 5 arguments (comma-separated).");
return;
}
// Validate first 4 args as integers
for (let i = 0; i < 4; i++) {
if (!Number.isInteger(Number(splitUpVal[i]))) {
console.log(
"First 4 arguments must all be integers. Also make sure that there is no space in between numbers. Example of what not to do: ..., 34 6, ..."
);
return;
}
}
// organizing before sending data
let tempCoords = {
firstChunk: { x: Number(splitUpVal[0]), y: Number(splitUpVal[1]) },
secondChunk: { x: Number(splitUpVal[2]), y: Number(splitUpVal[3]) },
};
// With 5th arg (boolean)
if (splitUpVal.length === 5) {
let inputBool = toBoolean(splitUpVal[4]);
if (inputBool === null) {
console.log("The fifth argument only accepts 'true' or 'false'.");
return;
}
multipleChunksDlUrl(tempCoords, inputBool);
} else {
// Only 4 args
multipleChunksDlUrl(tempCoords);
}
};
let updateButtons = () => {
let marker = document.querySelector(".maplibregl-marker");
if (!marker) {
isPointing = false;
chunkX = null;
chunkY = null;
pixelX = null;
pixelY = null;
chunkUrl = null;
refreshSetPointsInfo();
}
firstPointBtn.disabled = !isPointing;
secPointBtn.disabled = !isPointing;
let noChunkSelected =
mlChunkCoords.firstChunk.x == null && mlChunkCoords.secondChunk.x == null;
let hasAnUnassignedChunk =
mlChunkCoords.firstChunk.x == null || mlChunkCoords.secondChunk.x == null;
downloadChunkBtn.disabled = !isPointing || downloadingState;
viewChunkBtn.disabled = !isPointing;
downloadBtn.disabled = downloadingState || noChunkSelected;
downloadRegionBtn.disabled = downloadingState || hasAnUnassignedChunk;
manualDownloadBtn.disabled = downloadingState;
highlightBtn.disabled = noChunkSelected;
removePointsBtn.disabled = noChunkSelected;
savePresetBtn.disabled = noChunkSelected;
let presetDlBtns = document.querySelectorAll(".dlBtn");
presetDlBtns.forEach((btn) => {
btn.disabled = downloadingState;
});
};
// for the download bar
let updateDownloadBar = () => {
const progressElem = document.querySelector(".download-progress");
const textElem = document.querySelector(".download-text");
if (!progressElem || !textElem || totalImgsToBeDownloaded === 0) return;
const percent = Math.min(
100,
(currImgsDownloaded / totalImgsToBeDownloaded) * 100
);
progressElem.style.width = percent + "%";
textElem.textContent = `${currImgsDownloaded} / ${totalImgsToBeDownloaded}`;
};
updateButtons();
const mlCoordsOrganizer = (mlCoords) => {
console.log(mlCoords);
let { firstChunk, secondChunk } = structuredClone(mlCoords);
if (firstChunk.x == null && secondChunk.x == null) {
console.error("Null on both coords");
return;
}
if (secondChunk.x == null) {
secondChunk = { ...firstChunk };
return { firstChunk, secondChunk };
} else if (firstChunk.x == null) {
firstChunk = { ...secondChunk };
return { firstChunk, secondChunk };
}
// making sure that the coords that will be sent would be appropriate
// turns the first point to be the topleft corner and the second the bottom right
const result = {
firstChunk: {
x: Math.min(firstChunk.x, secondChunk.x),
y: Math.min(firstChunk.y, secondChunk.y),
},
secondChunk: {
x: Math.max(firstChunk.x, secondChunk.x),
y: Math.max(firstChunk.y, secondChunk.y),
},
};
return result;
};
let instanceInc = miscSection.querySelector(".instanceInc");
let instanceDec = miscSection.querySelector(".instanceDec");
instanceInc.addEventListener("click", () => changeInstances("inc"));
instanceDec.addEventListener("click", () => changeInstances("dec"));
let changeInstances = (type) => {
let clamp = (num, min, max) => {
return Math.min(Math.max(num, min), max);
};
let instanceInfo = miscSection.querySelector(".instanceInfo");
if (type == "inc") {
concurrentDlInstances++;
} else if (type == "dec") {
concurrentDlInstances--;
}
concurrentDlInstances = clamp(concurrentDlInstances, 1, maxDlInstances);
savedConfigs.concurrentDlInstances = concurrentDlInstances;
setGMValue("savedConfigs", savedConfigs);
instanceInfo.textContent = `Download instances: ${concurrentDlInstances}`;
};
// For other overlay scripts / to protect this script too lol
const nativeFetch = window.fetch.bind(window);
function makeFetchWrapper(fetchImpl) {
const wrapper = async (resource, init) => {
const url = new URL(
typeof resource === "string" ? resource : resource.url || "",
location.href // ensure absolute URL resolution
);
const isTile = url.pathname.endsWith(".png");
const x = url.searchParams.get("x");
const y = url.searchParams.get("y");
// Always call the currently wrapped fetch implementation
const res = await wrapper._target(resource, init);
// Highlight Tile logic
if (
isTile &&
isHightlightOn &&
highlightedChunksLinksArr.includes(url.href) &&
!downloadingState
) {
const cloned = res.clone();
const blob = await cloned.blob();
const bmp = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = bmp.width;
canvas.height = bmp.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(bmp, 0, 0);
ctx.fillStyle = "rgba(0, 0, 255, 0.2)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const modifiedBlob = await new Promise((resolve) =>
canvas.toBlob(resolve, "image/png")
);
const headers = new Headers(res.headers);
headers.delete("content-length");
headers.delete("content-encoding");
return new Response(modifiedBlob, {
status: res.status,
statusText: res.statusText,
headers,
});
}
// Point Selection logic / settintg event listeners for "exit point" btns
if (x != null && y != null) {
const pathnames = url.pathname.split("/");
chunkX = Number(pathnames.at(-2));
chunkY = Number(pathnames.at(-1));
pixelX = Number(x);
pixelY = Number(y);
chunkUrl = `https://backend.wplace.live/files/s0/tiles/${chunkX}/${chunkY}.png`;
isPointing = true;
updateButtons();
refreshSetPointsInfo();
console.log(`Pressed on ChunkX: ${chunkX}, ChunkY: ${chunkY}`);
const parent = document
.querySelector(".rounded-t-box")
?.querySelector("div");
if (parent) {
const pixelBtns = parent.querySelector(".hide-scrollbar");
let exitBtn = parent.querySelector(
"div.px-3:nth-child(1) > button:nth-child(2)"
);
let exitPointEvent = () => {
isPointing = false;
chunkX = null;
chunkY = null;
chunkUrl = null;
updateButtons();
refreshSetPointsInfo();
};
if (exitBtn) {
exitBtn.addEventListener("click", exitPointEvent);
}
let paintBtn = pixelBtns?.querySelector("button:nth-child(1)");
if (paintBtn) {
paintBtn.addEventListener("click", exitPointEvent);
}
} else {
console.error("Parent element not found");
}
}
// Default: return original response untouched
return res;
};
wrapper._target = fetchImpl;
return wrapper;
}
// Installs the first wrapper around the native fetch
let myFetchWrapper = makeFetchWrapper(nativeFetch);
// Trap window.fetch so other scripts can patch safely << for Wplace Overlay Pro
Object.defineProperty(window, "fetch", {
configurable: true,
get() {
return myFetchWrapper;
},
set(fn) {
console.log("Another script patched fetch, wrapping it.");
// Wrap the new fetch so recursion never happens
myFetchWrapper = makeFetchWrapper(fn);
},
});
// gonna need to optimize the code below, later.
const multipleChunksDlUrl = async (chunkCoords, name = "", safety = true) => {
if (downloadingState) return;
let organizedChunkCoords = mlCoordsOrganizer(chunkCoords);
let topleftX = organizedChunkCoords.firstChunk.x;
let topleftY = organizedChunkCoords.firstChunk.y;
let botRightX = organizedChunkCoords.secondChunk.x;
let botRightY = organizedChunkCoords.secondChunk.y;
console.log(
`downloading chunks: ${organizedChunkCoords.firstChunk.x}, ${organizedChunkCoords.firstChunk.y} | ${organizedChunkCoords.secondChunk.x}, ${organizedChunkCoords.secondChunk.y}`
);
let linksResultArr = getLinksFromChunkCoords(organizedChunkCoords);
downloadingState = true;
updateButtons();
let safetyThreshold = 70;
let chunkWidth = 1 + Number(botRightX - topleftX);
let imgsAmount = linksResultArr.length;
if (linksResultArr.length > safetyThreshold) {
if (safety) {
console.warn(
`You were about to download ${linksResultArr.length} images but was prevented by this precaution. If you intentionally wanted to download that much, you can type '${topLeftX}, ${topLeftY}, ${botRightX}, ${botRightY}, false' onto the manual chunk downloader. Good luck.`
);
return;
} else {
console.log("Better pray to God...");
}
}
totalImgsToBeDownloaded = imgsAmount;
currImgsDownloaded = 0;
updateDownloadBar();
let resultCanvas = await stitchImages(
linksResultArr,
chunkWidth,
concurrentDlInstances
);
let canvasName;
if (name == "") {
canvasName = `ch-${topleftX}, ${topleftY}, ${botRightX}, ${botRightY} time-${Date.now()}`;
}
canvasName = `${name} - ${Date.now()}`;
canvasDownloader(resultCanvas, canvasName);
};
// region downloader
const regionDl = async (
chunkCoords,
pixelCoords,
name = "",
safety = true
) => {
if (
pixelCoords.firstPixel.x == null ||
pixelCoords.secondPixel.x == null ||
downloadingState
)
return;
let organizedChunkCoords = mlCoordsOrganizer(chunkCoords);
let topleftX = organizedChunkCoords.firstChunk.x;
let topleftY = organizedChunkCoords.firstChunk.y;
let botRightX = organizedChunkCoords.secondChunk.x;
let botRightY = organizedChunkCoords.secondChunk.y;
let linksResultArr = getLinksFromChunkCoords(organizedChunkCoords);
downloadingState = true;
updateButtons();
let safetyThreshold = 70;
let chunkWidth = 1 + Number(botRightX - topleftX);
let imgsAmount = linksResultArr.length;
if (linksResultArr.length > safetyThreshold) {
if (safety) {
console.warn(
`You were about to download ${linksResultArr.length} images but was prevented by this precaution. If you intentionally wanted to download that much, Turn off the 'safety' under the Region Download section. Good luck.`
);
return;
} else {
console.log("Better pray to God...");
}
}
totalImgsToBeDownloaded = imgsAmount;
currImgsDownloaded = 0;
updateDownloadBar();
console.log(
`First point coords: Chunkx: ${topleftX}, ChunkY: ${topleftY}, PixelX: ${pixelCoords.firstPixel.x}, PixelY: ${pixelCoords.firstPixel.y}`
);
console.log(
`Second point coords: Chunkx: ${botRightX}, ChunkY: ${botRightY}, PixelX: ${pixelCoords.secondPixel.x}, PixelY: ${pixelCoords.secondPixel.y}`
);
let baseCanvas = await stitchImages(
linksResultArr,
chunkWidth,
concurrentDlInstances
);
console.log(
`canvas - Width: ${baseCanvas.width}, Height: ${baseCanvas.height}`
);
// translating the pixel coords onto canvas coords
let toCanvasCoord = (chunk, pixel, origin, tileSize) => {
return {
x: (chunk.x - origin.x) * tileSize + pixel.x,
y: (chunk.y - origin.y) * tileSize + pixel.y,
};
};
// Basically the "organizedChunkCoords.firstChunk" serves as the "topleft corner" or the 0,0 coords
// then we extract how far away are the points (on the amount of chunk tiles) from the topleft corner
// then we multiply it by 1000
// then the result would be then added by the pixel's coordinate (on the current chunk it is on) to get its coords on canvas.
let tileWidthAndHeight = 1000; // the width and height of a chunk, hopefully wont change lol
let canvasPixelCoords = {
firstPixel: toCanvasCoord(
chunkCoords.firstChunk,
pixelCoords.firstPixel,
organizedChunkCoords.firstChunk,
tileWidthAndHeight
),
secondPixel: toCanvasCoord(
chunkCoords.secondChunk,
pixelCoords.secondPixel,
organizedChunkCoords.firstChunk,
tileWidthAndHeight
),
};
console.log(chunkCoords);
console.log(pixelCoords);
console.log(canvasPixelCoords);
let organizedCanvasPixelCoords = pixelCoordsOrganizer(canvasPixelCoords);
// sets the width and height of the region
let regionWidth =
1 +
(organizedCanvasPixelCoords.secondPixel.x -
organizedCanvasPixelCoords.firstPixel.x);
let regionHeight =
1 +
(organizedCanvasPixelCoords.secondPixel.y -
organizedCanvasPixelCoords.firstPixel.y);
// creates a new canvas so the data can be put into it
let regionCanvas = document.createElement("canvas");
regionCanvas.width = regionWidth;
regionCanvas.height = regionHeight;
let regionCtx = regionCanvas.getContext("2d");
// copy the region onto the new canvas
regionCtx.drawImage(
baseCanvas,
organizedCanvasPixelCoords.firstPixel.x, // source x
organizedCanvasPixelCoords.firstPixel.y, // source y
regionWidth, // source width
regionHeight, // source height
0, // destination x
0, // destination y
regionWidth, // destination width
regionHeight // destination height
);
let canvasName;
if (name == "") {
canvasName = `ch-${organizedChunkCoords.firstChunk.x}, ${
organizedChunkCoords.firstChunk.y
} px-${organizedCanvasPixelCoords.firstPixel.x}, ${
organizedCanvasPixelCoords.firstPixel.y
} size ${regionWidth}, ${regionHeight} time-${Date.now()}`;
} else {
canvasName = `${name} - ${Date.now()}`;
}
// finally download the resulting canvas
await canvasDownloader(regionCanvas, canvasName);
};
let pixelCoordsOrganizer = (pixelCoords) => {
let { firstPixel, secondPixel } = structuredClone(pixelCoords);
const result = {
firstPixel: {
x: Math.min(firstPixel.x, secondPixel.x),
y: Math.min(firstPixel.y, secondPixel.y),
},
secondPixel: {
x: Math.max(firstPixel.x, secondPixel.x),
y: Math.max(firstPixel.y, secondPixel.y),
},
};
return result;
};
let getLinksFromChunkCoords = (chunkCoords) => {
console.log("getting the links from chunk coords.");
console.log(
"tempChunkCoords: " +
`First chunk {x: ${chunkCoords.firstChunk.x}, y: ${chunkCoords.firstChunk.y}}, Second chunk {x: ${chunkCoords.secondChunk.x}, y: ${chunkCoords.secondChunk.y}}`
);
let topleftX = chunkCoords.firstChunk.x,
topleftY = chunkCoords.firstChunk.y,
botRightX = chunkCoords.secondChunk.x,
botRightY = chunkCoords.secondChunk.y;
if (botRightX == null) {
botRightX = topleftX;
botRightY = topleftY;
}
let chunkWidth = 1 + Number(botRightX - topleftX);
let chunkHeight = 1 + Number(botRightY - topleftY);
console.log("chunkWidth: " + chunkWidth);
console.log("chunkHeight: " + chunkHeight);
let linksArr = [];
for (let j = 0; j < chunkHeight; j++) {
for (let i = 0; i < chunkWidth; i++) {
linksArr.push(
chunkTemplateUrl +
(Number(i) + Number(topleftX)) +
"/" +
(Number(j) + Number(topleftY)) +
".png"
);
}
}
return linksArr;
};
async function stitchImages(images, width, concurrency, delay = 150) {
const resizeImage = (img, maxWidth = 1000, maxHeight = 1000) => {
if (img.width <= maxWidth && img.height <= maxHeight) {
return img; // no need to resize
}
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
const newWidth = Math.floor(img.width * scale);
const newHeight = Math.floor(img.height * scale);
const c = document.createElement("canvas");
c.width = newWidth;
c.height = newHeight;
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0, newWidth, newHeight);
const resized = new Image();
resized.src = c.toDataURL();
return new Promise((resolve) => {
resized.onload = () => resolve(resized);
});
};
const createBlank = () => {
const c = document.createElement("canvas");
c.width = 1000;
c.height = 1000;
const ctx = c.getContext("2d");
ctx.fillStyle = "rgba(0,0,0,0)";
ctx.fillRect(0, 0, c.width, c.height);
return new Promise((resolve) => {
c.toBlob((blob) => {
const img = new Image();
img.src = URL.createObjectURL(blob);
img.onload = () => resolve(img);
});
});
};
async function fetchAndLoad(src) {
console.log("Downloading: " + src);
while (true) {
try {
const res = await fetch(src, { mode: "cors" });
if (res.status === 429) {
console.warn("Rate limited! Cooling down for 10s...");
await sleep(10000);
continue; // retry
}
if (res.status === 404) {
console.warn("404 Not Found, using blank:", src);
return await createBlank();
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const blob = await res.blob();
const img = await new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
console.log("Done loading img");
resolve(image);
};
image.onerror = () => reject(new Error("Decode error"));
image.src = URL.createObjectURL(blob);
});
return await resizeImage(img, 1000, 1000);
} catch (err) {
console.warn("Error loading, retrying:", src, err);
await sleep(2000); // small cooldown before retry
}
}
}
// Worker pool
async function loadImagesConcurrent(images, concurrency, delay) {
const results = new Array(images.length);
let index = 0;
async function worker() {
while (index < images.length) {
const i = index++;
const src = images[i];
const img = await fetchAndLoad(src);
results[i] = img;
currImgsDownloaded++;
updateDownloadBar();
await sleep(delay); // per-worker cooldown
}
}
const workers = Array.from({ length: concurrency }, () => worker()); // creates an array of promises (fetching imgs)
await Promise.all(workers);
return results;
}
// usage
const loadedImages = await loadImagesConcurrent(images, concurrency, delay);
// stitching
const columns = width;
const rows = Math.ceil(loadedImages.length / columns);
const imgWidth = 1000;
const imgHeight = 1000;
const canvas = document.createElement("canvas");
canvas.width = imgWidth * columns;
canvas.height = imgHeight * rows;
const ctx = canvas.getContext("2d");
loadedImages.forEach((img, index) => {
const x = (index % columns) * imgWidth;
const y = Math.floor(index / columns) * imgHeight;
ctx.drawImage(img, x, y);
});
return canvas;
}
let canvasDownloader = async (canvasToBeDownloaded, name) => {
canvasToBeDownloaded.toBlob((blob) => {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = name + ".png";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
// to enable the highlight to stay after downloading
downloadingState = false;
updateButtons();
}, "image/png");
};
})();