silly billy cursor
All checks were successful
/ test (push) Successful in 15s

This commit is contained in:
Soph :3 2024-09-19 12:57:38 +03:00
parent 3a430ab596
commit 789f4638dc
Signed by: sophie
GPG key ID: EDA5D222A0C270F2
7 changed files with 237 additions and 1 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -14,6 +14,8 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"ani-cursor": "^0.0.5",
"riff-file": "^1.0.3",
"sssg": "git+https://git.sad.ovh/sophie/sssg#e68ad369e9399d33f58678d2a3271cd631c2fe6a" "sssg": "git+https://git.sad.ovh/sophie/sssg#e68ad369e9399d33f58678d2a3271cd631c2fe6a"
} }
} }

BIN
website/assets/cursor.ani Normal file

Binary file not shown.

View file

@ -7,6 +7,7 @@ __TEMPLATE_HEAD__
<script type="module" src="scripts/lastfm.js"></script> <script type="module" src="scripts/lastfm.js"></script>
<script type="module" src="scripts/binkies.js"></script> <script type="module" src="scripts/binkies.js"></script>
<script type="module" src="scripts/commit.js"></script> <script type="module" src="scripts/commit.js"></script>
<script type="module" src="scripts/cursor.js"></script>
<link rel="stylesheet" href="assets/style.css"> <link rel="stylesheet" href="assets/style.css">
</head> </head>

View file

@ -0,0 +1,225 @@
// Code by
// https://www.npmjs.com/package/ani-cursor
// When importing the NPM package it just doesn't work.
// The babel version is so old it doesn't work on my browser.
import { RIFFFile } from "riff-file";
import { unpackArray, unpackString } from "byte-data";
type Chunk = {
format: string;
chunkId: string;
chunkData: {
start: number;
end: number;
};
subChunks: Chunk[];
};
// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
type AniMetadata = {
cbSize: number; // Data structure size (in bytes)
nFrames: number; // Number of images (also known as frames) stored in the file
nSteps: number; // Number of frames to be displayed before the animation repeats
iWidth: number; // Width of frame (in pixels)
iHeight: number; // Height of frame (in pixels)
iBitCount: number; // Number of bits per pixel
nPlanes: number; // Number of color planes
iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units)
bfAttributes: number; // ANI attribute bit flags
};
type ParsedAni = {
rate: number[] | null;
seq: number[] | null;
images: Uint8Array[];
metadata: AniMetadata;
artist: string | null;
title: string | null;
};
const DWORD = { bits: 32, be: false, signed: false, fp: false };
export function parseAni(arr: Uint8Array): ParsedAni {
const riff = new RIFFFile();
riff.setSignature(arr);
const signature = riff.signature as Chunk;
if (signature.format !== "ACON") {
throw new Error(
`Expected format. Expected "ACON", got "${signature.format}"`
);
}
// Helper function to get a chunk by chunkId and transform it if it's non-null.
function mapChunk<T>(chunkId: string, mapper: (chunk: Chunk) => T): T | null {
const chunk = riff.findChunk(chunkId) as Chunk | null;
return chunk == null ? null : mapper(chunk);
}
function readImages(chunk: Chunk, frameCount: number): Uint8Array[] {
return chunk.subChunks.slice(0, frameCount).map((c) => {
if (c.chunkId !== "icon") {
throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
}
return arr.slice(c.chunkData.start, c.chunkData.end);
});
}
const metadata = mapChunk("anih", (c) => {
const words = unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
return {
cbSize: words[0],
nFrames: words[1],
nSteps: words[2],
iWidth: words[3],
iHeight: words[4],
iBitCount: words[5],
nPlanes: words[6],
iDispRate: words[7],
bfAttributes: words[8],
};
});
if (metadata == null) {
throw new Error("Did not find anih");
}
const rate = mapChunk("rate", (c) => {
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
});
// chunkIds are always four chars, hence the trailing space.
const seq = mapChunk("seq ", (c) => {
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
});
const lists = riff.findChunk("LIST", true) as Chunk[] | null;
const imageChunk = lists?.find((c) => c.format === "fram");
if (imageChunk == null) {
throw new Error("Did not find fram LIST");
}
let images = readImages(imageChunk, metadata.nFrames);
let title: string | null = null;
let artist: string | null = null;
const infoChunk = lists?.find((c) => c.format === "INFO");
if (infoChunk != null) {
infoChunk.subChunks.forEach((c) => {
switch (c.chunkId) {
case "INAM":
title = unpackString(arr, c.chunkData.start, c.chunkData.end);
break;
case "IART":
artist = unpackString(arr, c.chunkData.start, c.chunkData.end);
break;
case "LIST":
// Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
if (c.format === "fram") {
images = readImages(c, metadata.nFrames);
}
break;
default:
// Unexpected subchunk
}
});
}
return { images, rate, seq, metadata, artist, title };
}
type AniCursorImage = {
frames: {
url: string;
percents: number[];
}[];
duration: number;
};
const JIFFIES_PER_MS = 1000 / 60;
// Generate CSS for an animated cursor.
//
// This function returns CSS containing a set of keyframes with embedded Data
// URIs as well as a CSS rule to the given selector.
export function convertAniBinaryToCSS(
selector: string,
aniBinary: Uint8Array
): string {
const ani = readAni(aniBinary);
const animationName = `ani-cursor-${uniqueId()}`;
const keyframes = ani.frames.map(({ url, percents }) => {
const percent = percents.map((num) => `${num}%`).join(", ");
return `${percent} { cursor: url(${url}), auto; }`;
});
// CSS properties with a animation type of "discrete", like `cursor`, actually
// switch half-way _between_ each keyframe percentage. Luckily this half-way
// measurement is applied _after_ the easing function is applied. So, we can
// force the frames to appear at exactly the % that we specify by using
// `timing-function` of `step-end`.
//
// https://drafts.csswg.org/web-animations-1/#discrete
const timingFunction = "step-end";
// Winamp (re)starts the animation cycle when your mouse enters an element. By
// default this approach would cause the animation to run continuously, even
// when the cursor is not visible. To match Winamp's behavior we add a
// `:hover` pseudo selector so that the animation only runs when the cursor is
// visible.
const pseudoSelector = ":hover";
// prettier-ignore
return `
@keyframes ${animationName} {
${keyframes.join("\n")}
}
${selector}${pseudoSelector} {
animation: ${animationName} ${ani.duration}ms ${timingFunction} infinite;
}
`;
}
function readAni(contents: Uint8Array): AniCursorImage {
const ani = parseAni(contents);
const rate = ani.rate ?? ani.images.map(() => ani.metadata.iDispRate);
const duration = sum(rate);
const frames = ani.images.map((image) => ({
url: curUrlFromByteArray(image),
percents: [] as number[],
}));
let elapsed = 0;
rate.forEach((r, i) => {
const frameIdx = ani.seq ? ani.seq[i] : i;
frames[frameIdx].percents.push((elapsed / duration) * 100);
elapsed += r;
});
return { duration: duration * JIFFIES_PER_MS, frames };
}
/* Utility Functions */
let i = 0;
const uniqueId = () => i++;
function base64FromDataArray(dataArray: Uint8Array): string {
return globalThis.window ? window.btoa(String.fromCharCode(...dataArray)) : Buffer.from(dataArray).toString("base64");
}
function curUrlFromByteArray(arr: Uint8Array) {
const base64 = base64FromDataArray(arr);
return `data:image/x-win-bitmap;base64,${base64}`;
}
function sum(values: number[]): number {
return values.reduce((total, value) => total + value, 0);
}

View file

@ -0,0 +1,8 @@
import { convertAniBinaryToCSS } from "./ani_parser";
(async () => {
const req = await fetch("/assets/cursor.ani");
const buf = new Uint8Array(await req.arrayBuffer());
const style = document.createElement("style");
style.innerHTML = convertAniBinaryToCSS("body", buf);
document.body.appendChild(style);
})();

View file

@ -18,4 +18,4 @@ export function timeAgo(input: number | Date) {
return formatter.format(Math.round(delta), rangeType); return formatter.format(Math.round(delta), rangeType);
} }
} }
} }