Skip to content

Instantly share code, notes, and snippets.

@oparisblue
Created March 16, 2024 12:27
Show Gist options
  • Save oparisblue/3881d98aa495dcf58067f8ce32133e57 to your computer and use it in GitHub Desktop.
Save oparisblue/3881d98aa495dcf58067f8ce32133e57 to your computer and use it in GitHub Desktop.
Simple Texture Viewer
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Texture Viewer</title>
</head>
<body>
<main>
<nav>
<div id="folders"></div>
<input type="search" id="search" placeholder="Search..." />
</nav>
<div class="note">
Not all mcmeta features are supported in this simple preview, so some
animations may appear differently to how they will in-game
</div>
<div id="textures"></div>
</main>
<style>
body,
html {
background-color: #212121;
height: 100%;
padding: 0;
margin: 0;
}
.note {
color: #eee;
padding: 20px;
font-family: sans-serif;
font-size: 12px;
}
nav {
display: flex;
position: sticky;
top: 0;
flex-direction: row;
align-items: center;
background-color: #212121;
padding: 10px 20px 10px 20px;
* {
height: 32px;
}
#folders {
flex-grow: 1;
display: flex;
flex-direction: row;
gap: 10px;
button {
color: #eee;
border-style: none;
background-color: #212121;
padding: 0;
margin: 0;
&.selected {
color: #f00;
}
}
}
#search {
width: 200px;
}
}
main {
#textures {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 20px;
padding: 0 20px 20px 20px;
img {
width: 64px;
height: 64px;
image-rendering: pixelated;
}
}
}
.tooltip {
position: fixed;
padding: 10px;
background-color: #111;
color: #eee;
pointer-events: none;
font-family: sans-serif;
font-size: 14px;
border: solid 1px #eee;
}
</style>
<script>
const USER_NAME = "malcolmriley";
const REPO_NAME = "unused-textures";
const BRANCH_NAME = "master";
const TREE_URL = `https://api.github.com/repos/${USER_NAME}/${REPO_NAME}/git/trees/${BRANCH_NAME}?recursive=1`;
const CONTENT_URL = `https://raw.githubusercontent.com/${USER_NAME}/${REPO_NAME}/${BRANCH_NAME}/`;
// Minecraft animations are constrained to TPS. Under ideal load, Minecraft runs at 20 ticks per second
const TIME_PER_FRAME_MS = 1000 / 20;
/** @typedef {Map<string, Map<string, {isAnimated: boolean}>>} Folders */
async function getAllFilesFromGithub() {
// We use the Github API as it's the easiest serverless way to get a list of all files in
// the directory
const githubDirectoryListing = await fetch(TREE_URL);
const { tree } = await githubDirectoryListing.json();
/** @type Folders */
const folders = new Map();
const regexForFolderNameAndMcMeta = /([^\/]+)\/([^.]+).*?(\.mcmeta)?$/;
for (const item of tree) {
const matches = item.path.match(regexForFolderNameAndMcMeta);
if (!matches) continue;
/** @type [unknown, string, string, string?] */
const [_, folder, name, mcMeta] = matches;
if (!folders.has(folder)) folders.set(folder, new Map());
const folderMap = folders.get(folder);
const isAnimated = mcMeta != null;
const itemExists = folderMap.has(name);
if (!itemExists || isAnimated) {
folderMap.set(name, { isAnimated });
}
}
return folders;
}
class TooltipElement extends HTMLElement {
constructor() {
super();
this.teardown;
}
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
const text = this.getAttribute("text");
const container = document.createElement("div");
container.addEventListener("mouseenter", (event) => {
const tooltip = document.createElement("div");
tooltip.classList.add("tooltip");
tooltip.innerText = text;
this.positionTooltip(tooltip, event);
document.body.append(tooltip);
const moveListener = document.body.addEventListener(
"mousemove",
(moveEvent) => {
this.positionTooltip(tooltip, moveEvent);
}
);
this.teardown = () => {
document.body.removeEventListener("mousemove", moveListener);
tooltip.remove();
};
});
container.addEventListener("mouseleave", () => {
this.teardown?.();
});
const slot = document.createElement("slot");
container.append(slot);
shadow.append(container);
}
/**
* @param {HTMLElement} tooltip
* @param {MouseEvent} mouseEvent
*/
positionTooltip(tooltip, event) {
const TOOLTIP_OFFSET_PX = 10;
const RIGHT_BREAKPOINT_PX = 300;
tooltip.style.top = `${event.clientY + TOOLTIP_OFFSET_PX}px`;
if (event.clientX >= window.innerWidth - RIGHT_BREAKPOINT_PX) {
tooltip.style.right = `${
window.innerWidth - event.clientX + TOOLTIP_OFFSET_PX
}px`;
return;
}
tooltip.style.left = `${event.clientX + TOOLTIP_OFFSET_PX}px`;
}
disconnectedCallback() {
this.teardown?.();
}
}
customElements.define("tool-tip", TooltipElement);
class McMetaRenderer extends HTMLElement {
constructor() {
super();
this.intervalId = null;
}
async connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
const src = this.getAttribute("data-src");
const [image, mcMeta] = await Promise.all([
this.loadImage(src),
this.loadMcMeta(src),
]);
const div = document.createElement("div");
div.style.width = "64px";
div.style.height = "64px";
div.style.backgroundImage = `url(./${src}.png)`;
div.style.backgroundRepeat = "no-repeat";
div.style.backgroundSize = "cover";
div.style.imageRendering = "pixelated";
let currentFrame = 0;
const frames =
mcMeta.frames ??
[...new Array(image.numberOfFrames)].map((_, i) => ({
index: i,
time: 1,
}));
let timeOnCurrentFrame = frames[0].time;
this.intervalId = setInterval(() => {
timeOnCurrentFrame--;
if (timeOnCurrentFrame <= 0) {
currentFrame = (currentFrame + 1) % frames.length;
timeOnCurrentFrame = frames[currentFrame].time;
}
const frameOffset = frames[currentFrame].index * 64;
div.style.backgroundPosition = `0 -${frameOffset}px`;
}, TIME_PER_FRAME_MS);
shadow.append(div);
}
/**
* @param {string} src
* @return {Promise<{frameSize: number, numberOfFrames: number, image: HTMLImageElement}>}
*/
async loadImage(src) {
return new Promise((resolve) => {
const image = new Image();
image.src = `./${src}.png`;
image.addEventListener("load", () => {
// All frames must be the same size, and square, so we can just use the width.
const frameSize = image.width;
// Animation frames are stored in a vertical strip, with each frame directly
// underneath the previous one.
const numberOfFrames = image.height / frameSize;
resolve({ frameSize, numberOfFrames, image });
});
});
}
/**
* @param {string} src
* @return {Promise<{frameTime: number, frames?: (number | {index: number, time: number})[]}>}
*/
async loadMcMeta(src) {
// Load these from github because CORS prevents us from loading them when running
// locally without a server
const mcMetaFile = await fetch(`${CONTENT_URL}${src}.png.mcmeta`);
const mcMetaJson = await mcMetaFile.json();
const frameTime = mcMetaJson?.animation?.frametime ?? 1;
return {
frames: mcMetaJson?.animation?.frames?.map((item) =>
typeof item === "number" ? { index: item, time: frameTime } : item
),
};
}
disconnectedCallback() {
if (this.intervalId != null) {
clearInterval(this.intervalId);
}
}
}
customElements.define("mc-meta", McMetaRenderer);
/**
* @param {string} textureName
* @param {boolean} isAnimated
* @param {string} selectedFolder
* @return {HTMLElement}
*/
function getImageRenderer(textureName, isAnimated, selectedFolder) {
if (isAnimated) {
const mcMetaRender = document.createElement("mc-meta");
mcMetaRender.setAttribute(
"data-src",
`${selectedFolder}/${textureName}`
);
return mcMetaRender;
}
const image = document.createElement("img");
image.src = `./${selectedFolder}/${textureName}.png`;
return image;
}
/**
* @param {Folders} folders
* @param {string} selectedFolder
* @param {string} searchTerm
*/
function showResults(folders, selectedFolder, searchTerm) {
const folder = folders.get(selectedFolder);
const results = document.createDocumentFragment();
const lowerSearchTerm = searchTerm.toLowerCase().trim();
for (const [textureName, { isAnimated }] of folder) {
if (
lowerSearchTerm !== "" &&
!textureName.includes(lowerSearchTerm)
) {
continue;
}
const tooltipWithRenderer = document.createElement("tool-tip");
tooltipWithRenderer.setAttribute("text", textureName);
tooltipWithRenderer.append(
getImageRenderer(textureName, isAnimated, selectedFolder)
);
results.append(tooltipWithRenderer);
}
const container = document.querySelector("#textures");
container.innerHTML = "";
container.append(results);
}
/** @param {string[]} folderNames */
function addFolderButtons(folderNames) {
const menu = document.querySelector("#folders");
for (let i = 0; i < folderNames.length; i++) {
const button = document.createElement("button");
button.innerText = folderNames[i];
if (i === 0) button.classList.add("selected");
button.addEventListener("click", () => {
document.querySelector(".selected").classList.remove("selected");
button.classList.add("selected");
showResultsFromFilters();
});
menu.appendChild(button);
}
}
/** @type Folders */
let folders;
function showResultsFromFilters() {
const selectedFolder = document.querySelector(".selected").innerText;
const search = document.querySelector("#search").value;
showResults(window.folders, selectedFolder, search);
}
window.addEventListener("load", async () => {
window.folders = await getAllFilesFromGithub();
const folderNames = [...window.folders.keys()].filter(
(name) => name !== "gui"
);
addFolderButtons(folderNames);
document
.querySelector("#search")
.addEventListener("input", showResultsFromFilters);
showResultsFromFilters();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment