-
-
Save oparisblue/3881d98aa495dcf58067f8ce32133e57 to your computer and use it in GitHub Desktop.
Simple Texture Viewer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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