Files
decePubClient/wwwroot/js/blurhash.js
T
thepraandClaude Opus 5.5 2024798732 Custom emoji, downvotes, blurhash and focal points on the cards
What other platforms send now shows as they meant it:
- custom emoji (a post's or account's :shortcode:) as their pictures in
  posts, names, content warnings, bios and fields: only the shortcodes the
  server listed, only http(s) pictures, never inside a tag, the shortcode
  kept as alt text;
- a threadiverse post's downvotes next to its favourites;
- blurhash placeholders until a picture loads (wwwroot/js/blurhash.js, a
  small decoder that watches the page), and a picture's focal point;
- an event's online link, participants and capacity, and whether it is
  tentative or cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 17:55:39 +02:00

96 lines
3.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
function paint(image) {
if (image.dataset.blurhashPainted || (image.complete && image.naturalWidth))
return
image.dataset.blurhashPainted = "1"
try {
const pixels = decode(image.dataset.blurhash)
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)
image.style.backgroundImage = `url(${canvas.toDataURL()})`
image.style.backgroundSize = "cover"
image.addEventListener("load", () => image.style.removeProperty("background-image"), { once: true })
} catch (e) {
window.logFromJs?.(e.message, "blurhash")
}
}
const scan = (root) => root.querySelectorAll("img[data-blurhash]").forEach(paint)
new MutationObserver(records => {
for (const record of records)
for (const node of record.addedNodes)
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches("img[data-blurhash]"))
paint(node)
scan(node)
}
}).observe(document.documentElement, { childList: true, subtree: true })
scan(document)
})()