Pull ani-cursor out into its own package

This commit is contained in:
Jordan Eldredge 2020-12-09 20:10:45 -08:00
parent f6cf3a1897
commit c48e2c4156
21 changed files with 2600 additions and 162 deletions

View file

@ -24,10 +24,13 @@ jobs:
yarn workspace webamp type-check
- name: Build
run: |
yarn workspace ani-cursor build
yarn workspace webamp build
yarn workspace webamp build-library
- name: Run Unit Tests
run: yarn workspace webamp test
run: |
yarn workspace ani-cursor test
yarn workspace webamp test
- name: Run Integration Tests
run: yarn workspace webamp integration-tests
env:

1
packages/ani-cursor/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

View file

@ -0,0 +1,34 @@
## Install
```bash
yarn add ani-cursor
```
or
```bash
npm install ani-cursor
```
## Usage Example
```JavaScript
import {convertAniBinaryToCSS} from 'ani-cursor';
async function applyCursor(selector, aniUrl) {
const response = await fetch(aniUrl);
const data = new Uint8Array(await response.arrayBuffer());
const style = document.createElement('style');
style.innerText = convertAniBinaryToCSS(selector, data);
document.head.appendChild(style);
}
const h1 = document.createElement('h1');
h1.id = 'pizza';
h1.innerText = 'Pizza Time!';
document.body.appendChild(h1);
applyCursor("#pizza", "https://archive.org/cors/tucows_169906_Pizza_cursor/pizza.ani");
```

View file

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Edge cases piano.ani 1`] = `
"
@keyframes ani-cursor-3 {
0% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAA0ACQDoAgAAFgAAACgAAAAgAAAAQAAAAAEABA...), auto; }
20% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAA0ACQDoAgAAFgAAACgAAAAgAAAAQAAAAAEABA...), auto; }
40% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAA0ACQDoAgAAFgAAACgAAAAgAAAAQAAAAAEABA...), auto; }
60% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAA0ACQDoAgAAFgAAACgAAAAgAAAAQAAAAAEABA...), auto; }
80% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAA0ACQDoAgAAFgAAACgAAAAgAAAAQAAAAAEABA...), auto; }
}
#example:hover {
animation: ani-cursor-3 1250ms step-end infinite;
}
"
`;
exports[`Green Dimension v2.wsz eqslid.cur 1`] = `
"
@keyframes ani-cursor-2 {
0% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIA...), auto; }
50% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIA...), auto; }
}
#example:hover {
animation: ani-cursor-2 266.6666666666667ms step-end infinite;
}
"
`;
exports[`Super_Mario_Amp_2.wsz close.cur 1`] = `
"
@keyframes ani-cursor-1 {
0% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
7.246376811594203% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
14.492753623188406% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
21.73913043478261% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
28.985507246376812%, 38.405797101449274%, 44.927536231884055% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
34.05797101449276%, 42.028985507246375%, 47.10144927536232% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
48.55072463768116% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
49.275362318840585% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
50.72463768115942%, 53.62318840579711%, 56.52173913043478% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
52.17391304347826%, 55.072463768115945% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAACgAAAAgAAAAQAAAAAEACA...), auto; }
}
#example:hover {
animation: ani-cursor-1 2300ms step-end infinite;
}
"
`;
exports[`Super_Mario_Amp_2.wsz eqslid.cur 1`] = `
"
@keyframes ani-cursor-0 {
0% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
12.5% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
25% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
37.5% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
50% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
62.5% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
75% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
87.5% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoDAAAFgAAACgAAAAgAAAAQAAAAAEAGA...), auto; }
}
#example:hover {
animation: ani-cursor-0 1333.3333333333335ms step-end infinite;
}
"
`;

View file

@ -0,0 +1,39 @@
import fs from "fs";
import path from "path";
import { convertAniBinaryToCSS } from "../";
const LONG_BASE_64 = /([A-Za-z0-9+/=]{50})[A-Za-z0-9+/=]+/g;
// Parse a `.ani` in our fixture directory and trim down the image data for use
// in snapshot tests.
function readPathCss(filePath: string): string {
const buffer = fs.readFileSync(path.join(__dirname, "./fixtures/", filePath));
return convertAniBinaryToCSS("#example", buffer).replace(
LONG_BASE_64,
"$1..."
);
}
// https://skins.webamp.org/skin/6e30f9e9b8f5719469809785ae5e4a1f/Super_Mario_Amp_2.wsz/
describe("Super_Mario_Amp_2.wsz", () => {
test("eqslid.cur", async () => {
expect(readPathCss("Super_Mario_Amp_2/eqslid.cur")).toMatchSnapshot();
});
test("close.cur", async () => {
expect(readPathCss("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
});
});
// https://skins.webamp.org/skin/4308a2fc648033bf5fe7c4d56a5c8823/Green-Dimension-V2.wsz/
describe("Green Dimension v2.wsz", () => {
test("eqslid.cur", async () => {
expect(readPathCss("Green Dimension v2/eqslid.cur")).toMatchSnapshot();
});
});
describe("Edge cases", () => {
test("piano.ani", async () => {
expect(readPathCss("piano.ani")).toMatchSnapshot();
});
});

View file

@ -0,0 +1,6 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript",
],
};

View file

@ -1,45 +1,26 @@
import { parseAni } from "./aniParser";
import { AniCursorImage } from "./types";
import * as Utils from "./utils";
import * as FileUtils from "./fileUtils";
import { parseAni } from "./parser";
type AniCursorImage = {
frames: {
url: string;
percents: number[];
}[];
duration: number;
};
const JIFFIES_PER_MS = 1000 / 60;
export function readAni(contents: Uint8Array): AniCursorImage {
const ani = parseAni(contents);
const rate = ani.rate ?? ani.images.map(() => ani.metadata.iDispRate);
const duration = Utils.sum(rate);
const frames = ani.images.map((image) => ({
url: FileUtils.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 };
}
// Generate CSS for an animated cursor.
//
// Based on https://css-tricks.com/forums/topic/animated-cursor/
//
// Browsers won't render animated cursor images specified via CSS. For `.ani`
// images, we already have the frames as indiviual images, so we create a CSS
// animation.
//
// This function returns CSS containing a set of keyframes with embedded Data
// URIs as well as a CSS rule to the given selector.
//
// **Note:** This does not seem to work on Safari. I've filed an issue here:
// https://bugs.webkit.org/show_bug.cgi?id=219564
export function aniCss(selector: string, ani: AniCursorImage): string {
const animationName = `webamp-ani-cursor-${Utils.uniqueId()}`;
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(", ");
@ -62,14 +43,51 @@ export function aniCss(selector: string, ani: AniCursorImage): string {
// visible.
const pseudoSelector = ":hover";
// prettier-ignore
return `
@keyframes ${animationName} {
@keyframes ${animationName} {
${keyframes.join("\n")}
}
${selector}${pseudoSelector} {
animation: ${animationName} ${
ani.duration
}ms ${timingFunction} infinite;
}
`;
}
${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 window.btoa(String.fromCharCode(...dataArray));
}
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,27 @@
{
"name": "ani-cursor",
"version": "1.0.0",
"description": "Render .ani cursors as CSS animations in the browser",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"author": "Jordan Eldredge <jordan@jordaneldredge.com>",
"license": "MIT",
"scripts": {
"build": "tsc",
"test": "jest",
"prepublish": "tsc"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@babel/preset-env": "^7.12.7",
"@babel/preset-typescript": "^7.12.7",
"@types/jest": "^26.0.18",
"babel-jest": "^26.6.3",
"jest": "^26.6.3",
"typescript": "^4.1.2"
},
"dependencies": {
"byte-data": "18.1.1",
"riff-file": "^1.0.3"
}
}

View file

@ -12,7 +12,7 @@ type Chunk = {
};
// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
export type AniMetadata = {
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
@ -24,7 +24,7 @@ export type AniMetadata = {
bfAttributes: number; // ANI attribute bit flags
};
export type ParsedAni = {
type ParsedAni = {
rate: number[] | null;
seq: number[] | null;
images: Uint8Array[];

View file

@ -0,0 +1,33 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
"sourceMap": true /* Generates corresponding '.map' file. */,
"outDir": "./dist" /* Redirect output structure to the directory. */,
"downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */,
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"strictNullChecks": true /* Enable strict null checks. */,
"strictFunctionTypes": true /* Enable strict checking of function types. */,
"strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */,
"strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
"alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
/* Additional Checks */
"noUnusedLocals": true /* Report errors on unused locals. */,
"noUnusedParameters": true /* Report errors on unused parameters. */,
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
/* Module Resolution Options */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"declaration": true,
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}

View file

@ -1,58 +0,0 @@
import fs from "fs";
import path from "path";
import { parseAni } from "./aniParser";
import { readAni, aniCss } from "./aniUtils";
// Parse a `.ani` in our fixture directory and trim down the image data for use
// in snapshot tests.
function parsePath(filePath: string) {
const buffer = fs.readFileSync(
path.join(__dirname, "./__tests__/fixtures/ani/", filePath)
);
const ani = parseAni(buffer);
const imgString = ani.images.map((image) => image.slice(0, 60).join(""));
// @ts-ignore
ani.images = `${imgString}...`;
return ani;
}
function readPathCss(filePath: string) {
const buffer = fs.readFileSync(
path.join(__dirname, "./__tests__/fixtures/ani/", filePath)
);
const ani = readAni(buffer);
ani.frames = ani.frames.map(({ url, percents }) => ({
url: url.slice(0, 60),
percents,
}));
return aniCss("#example", ani);
}
// https://skins.webamp.org/skin/6e30f9e9b8f5719469809785ae5e4a1f/Super_Mario_Amp_2.wsz/
describe("Super_Mario_Amp_2.wsz", () => {
test("eqslid.cur", async () => {
expect(parsePath("Super_Mario_Amp_2/eqslid.cur")).toMatchSnapshot();
expect(readPathCss("Super_Mario_Amp_2/eqslid.cur")).toMatchSnapshot();
});
test("close.cur", async () => {
expect(parsePath("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
expect(readPathCss("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
});
});
// https://skins.webamp.org/skin/4308a2fc648033bf5fe7c4d56a5c8823/Green-Dimension-V2.wsz/
describe("Green Dimension v2.wsz", () => {
test("eqslid.cur", async () => {
expect(parsePath("Green Dimension v2/eqslid.cur")).toMatchSnapshot();
expect(readPathCss("Green Dimension v2/eqslid.cur")).toMatchSnapshot();
});
});
describe("Edge cases", () => {
test("piano.ani", async () => {
expect(parsePath("piano.ani")).toMatchSnapshot();
expect(readPathCss("piano.ani")).toMatchSnapshot();
});
});

View file

@ -7,7 +7,7 @@ import { SkinImages } from "../types";
import { createSelector } from "reselect";
import Css from "./Css";
import ClipPaths from "./ClipPaths";
import { aniCss } from "../aniUtils";
import { convertAniBinaryToCSS } from "ani-cursor";
const CSS_PREFIX = "#webamp";
@ -92,7 +92,7 @@ const getCssRules = createSelector(
case "cur":
return `${selector} {cursor: url(${cursor.url}), auto}`;
case "ani": {
return aniCss(selector, cursor.ani);
return convertAniBinaryToCSS(selector, cursor.aniData);
}
}
});

View file

@ -4,7 +4,6 @@ import SKIN_SPRITES, { Sprite } from "./skinSprites";
import { DEFAULT_SKIN } from "./constants";
import * as Utils from "./utils";
import * as FileUtils from "./fileUtils";
import * as AniUtils from "./aniUtils";
export const getFileExtension = (fileName: string): string | null => {
const matches = /\.([a-z]{3,4})$/i.exec(fileName);
@ -143,7 +142,7 @@ export async function getCursorFromFilename(
const contents = file.contents as Uint8Array;
if (arrayStartsWith(contents, RIFF_MAGIC)) {
try {
return { type: "ani", ani: AniUtils.readAni(contents) };
return { type: "ani", aniData: contents };
} catch (e) {
console.error(e);
return null;

View file

@ -75,14 +75,6 @@ export type Band =
export type Slider = Band | "preamp";
export type AniCursorImage = {
frames: {
url: string;
percents: number[];
}[];
duration: number;
};
export type CursorImage =
| {
type: "cur";
@ -90,7 +82,7 @@ export type CursorImage =
}
| {
type: "ani";
ani: AniCursorImage;
aniData: Uint8Array;
};
// TODO: Use a type to ensure these keys mirror the CURSORS constant in

View file

@ -147,10 +147,9 @@
"trailingComma": "es5"
},
"dependencies": {
"byte-data": "18.1.1",
"ani-cursor": "^1.0.0",
"eslint-plugin-react-hooks": "^2.1.2",
"fscreen": "^1.0.2",
"redux-sentry-middleware": "^0.1.3",
"riff-file": "^1.0.3"
"redux-sentry-middleware": "^0.1.3"
}
}

2363
yarn.lock

File diff suppressed because it is too large Load diff