// Blurhash placeholders (https://blurha.sh): an image carrying data-blurhash shows the colours its hash encodes, // decoded into a small canvas, until it has loaded. The page is watched, so the cards rendered later get theirs too. (() => { const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" const size = 32 const base83 = (text) => { let value = 0 for (const c of text) { const digit = alphabet.indexOf(c) if (digit < 0) throw new Error("not a blurhash") value = value * 83 + digit } return value } const toLinear = (value) => { const v = value / 255 return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) } const toSrgb = (value) => { const v = Math.max(0, Math.min(1, value)) return Math.round(v <= 0.0031308 ? v * 12.92 * 255 : (1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255) } const signedSquare = (value) => Math.sign(value) * value * value // the hash's pixels at size × size, or null when it is not a blurhash function decode(hash) { if (!hash || hash.length < 6) return null const dimensions = base83(hash[0]) const columns = (dimensions % 9) + 1, rows = Math.floor(dimensions / 9) + 1 if (hash.length !== 4 + 2 * columns * rows) return null const maximum = (base83(hash[1]) + 1) / 166 const average = base83(hash.slice(2, 6)) const colours = [[toLinear(average >> 16), toLinear((average >> 8) & 255), toLinear(average & 255)]] for (let i = 1; i < columns * rows; i++) { const value = base83(hash.slice(4 + i * 2, 6 + i * 2)) const channel = (quantised) => signedSquare((quantised - 9) / 9) * maximum colours.push([channel(Math.floor(value / 361)), channel(Math.floor(value / 19) % 19), channel(value % 19)]) } const pixels = new Uint8ClampedArray(size * size * 4) for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { let r = 0, g = 0, b = 0 for (let j = 0; j < rows; j++) for (let i = 0; i < columns; i++) { const basis = Math.cos(Math.PI * x * i / size) * Math.cos(Math.PI * y * j / size) const colour = colours[i + j * columns] r += colour[0] * basis g += colour[1] * basis b += colour[2] * basis } const p = 4 * (x + y * size) pixels[p] = toSrgb(r) pixels[p + 1] = toSrgb(g) pixels[p + 2] = toSrgb(b) pixels[p + 3] = 255 } return pixels } // paints an element's blurhash as its background: an image's until it has loaded, anything else's (the placeholder of // sensitive media) for good. A changed hash, or an image given another source, is painted again. function paint(element) { const hash = element.dataset.blurhash const isImage = element.tagName === "IMG" if (!hash || element.dataset.blurhashPainted === hash || (isImage && element.complete && element.naturalWidth)) return element.dataset.blurhashPainted = hash try { const pixels = decode(hash) if (!pixels) return const canvas = document.createElement("canvas") canvas.width = size canvas.height = size canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0) element.style.backgroundImage = `url(${canvas.toDataURL()})` element.style.backgroundSize = "cover" if (isImage) element.addEventListener("load", () => element.style.removeProperty("background-image"), { once: true }) } catch (e) { window.logFromJs?.(e.message, "blurhash") } } const scan = (root) => root.querySelectorAll("[data-blurhash]").forEach(paint) new MutationObserver(records => { for (const record of records) { if (record.type === "attributes") { // another picture in the same element (the viewer's previous and next): painted anew if (record.attributeName === "src") delete record.target.dataset.blurhashPainted paint(record.target) continue } for (const node of record.addedNodes) if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches("[data-blurhash]")) paint(node) scan(node) } } }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-blurhash", "src"] }) scan(document) })()