Skip to content

Instantly share code, notes, and snippets.

@acorn1010
Created May 13, 2023 21:15
Show Gist options
  • Save acorn1010/2a155a769d744de2cfcce8442af1b88b to your computer and use it in GitHub Desktop.
Save acorn1010/2a155a769d744de2cfcce8442af1b88b to your computer and use it in GitHub Desktop.
useAnimation
/** A set that can have multiple entries of the same value. Thanks, ChatGPT! */
export class Multiset<T> {
private readonly elements = new Map<T, number>();
/** Total number of elements in the Multiset. */
private _size = 0;
/** Returns the number of elements in the set after adding `element`. */
add(element: T): number {
const count = 1 + (this.elements.get(element) || 0);
this.elements.set(element, count);
++this._size;
return count;
}
/** Returns `true` if the element was removed from the set. */
remove(element: T): boolean {
const currentCount = this.elements.get(element) || 0;
if (currentCount < 1) {
return false;
}
--this._size;
if (currentCount === 1) {
this.elements.delete(element);
} else {
this.elements.set(element, currentCount - 1);
}
return true;
}
/** Returns `true` if `element` is in the Multiset */
has(element: T): boolean {
return this.elements.has(element);
}
/**
* Returns the total number of elements in the Multiset (e.g. adding the same key 3 times counts
* as 3 elements).
*/
size() {
return this._size;
}
/** Returns an iterator over the values in the Multiset. */
*[Symbol.iterator](): Iterator<T> {
for (const value of this.elements.keys()) {
yield value;
}
}
}
/**
* JS Implementation of MurmurHash3 (r136) (as of May 20, 2011)
*
* @author <a href="mailto:gary.court@gmail.com">Gary Court</a>
* @see http://github.com/garycourt/murmurhash-js
* @author <a href="mailto:aappleby@gmail.com">Austin Appleby</a>
* @see http://sites.google.com/site/murmurhash/
*
* @param {string} key ASCII only
* @param {number} seed Positive integer only
* @return {number} 32-bit positive integer hash
*/
export function weakHash(key: string, seed: number) {
let remainder, bytes, h1, h1b, c1, c2, k1, i;
remainder = key.length & 3; // key.length % 4
bytes = key.length - remainder;
h1 = Math.abs(seed);
c1 = 0xcc9e2d51;
c2 = 0x1b873593;
i = 0;
while (i < bytes) {
k1 =
((key.charCodeAt(i) & 0xff)) |
((key.charCodeAt(++i) & 0xff) << 8) |
((key.charCodeAt(++i) & 0xff) << 16) |
((key.charCodeAt(++i) & 0xff) << 24);
++i;
k1 = ((((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16))) & 0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 = ((((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16))) & 0xffffffff;
h1 ^= k1;
h1 = (h1 << 13) | (h1 >>> 19);
h1b = ((((h1 & 0xffff) * 5) + ((((h1 >>> 16) * 5) & 0xffff) << 16))) & 0xffffffff;
h1 = (((h1b & 0xffff) + 0x6b64) + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16));
}
k1 = 0;
// Unwrapped switch statement because TypeScript was complaining about case fallthrough.
if (remainder === 3) {
k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
}
if (remainder >= 2) {
k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
}
if (remainder >= 1) {
k1 ^= (key.charCodeAt(i) & 0xff);
}
k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff;
h1 ^= k1;
h1 ^= key.length;
h1 ^= h1 >>> 16;
h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff;
h1 ^= h1 >>> 13;
h1 = ((((h1 & 0xffff) * 0xc2b2ae35) + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff;
h1 ^= h1 >>> 16;
return h1 >>> 0;
}
import React, {CSSProperties, useEffect, useRef} from 'react';
import {Helmet} from 'react-helmet';
import { Multiset } from '@shared/collections/Multiset';
import { weakHash } from '@shared/utils/StringUtils';
import {createGlobalStore} from '~/state/createGlobalStore';
import {isEqual} from 'moderndash';
/** An animation involving multiple "frames" / images. Each frame will appear for `durationMs` / `frames.length`. */
type AnimationFrames = {type: 'frames', frames: string[], durationMs: number};
/** An animation that animation from `from` props to `to`. */
type AnimationCss = {type: 'css', from: CSSProperties, to: CSSProperties, durationMs: number};
type AnimationType = AnimationFrames | AnimationCss;
const store = createGlobalStore({} as {[hash: number]: AnimationType});
/**
* List of frames that have been preloaded. This reduces flicker by ensuring
* that frames are loaded before they need to be rendered by the keyframe.
*/
const loadedFrames = new Set<string>();
/**
* Provides the animations for the `useAnimation` hook. This must be included
* _once_ (and only once) in the DOM in order to inject the CSS keyframes needed
* by `useAnimation`.
*/
export function AnimationProvider() {
const animations = store.useAll();
const animationKeyframes: string[] = [];
for (const [hash, animation] of Object.entries(animations)) {
animationKeyframes.push(makeKeyframe(hash, animation));
}
return <Helmet><style type='text/css'>{animationKeyframes.join('\n')}</style></Helmet>;
}
/** Returns a CSS keyframe representing the given `animation`. */
function makeKeyframe(key: string, animation: AnimationType): string {
switch (animation.type) {
case 'frames': return makeAnimationKeyframe(key, animation);
case 'css': return makeCssKeyframe(key, animation);
}
}
function makeCssKeyframe(key: string, {from, to, durationMs}: AnimationCss) {
const fromRows = Object.entries(from).map(([key, value]) => ` ${key}: ${value};`);
const toRows = Object.entries(to).map(([key, value]) => ` ${key}: ${value};`);
return `
@keyframes animation-${key} {
from {
${fromRows}
}
to {
${toRows}
}
}
.animation-${key} {
animation-name: animation-${key};
animation-duration: ${durationMs / 1_000}s;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
`;
}
function makeAnimationKeyframe(key: string, {frames, durationMs}: AnimationFrames) {
// Preload frames
for (const frame of frames) {
if (!loadedFrames.has(frame)) {
loadedFrames.add(frame);
const image = new Image();
image.src = frame;
}
}
const keyframes =
frames.map(
(frame, idx) =>
` ${(100 * idx) / frames.length}% { background-image: url('${frame}'); }`);
return `
@keyframes animation-${key} {
${keyframes.join('\n')}
100% { background-image: none; display: none; }
}
.animation-${key} {
animation-name: animation-${key};
animation-duration: ${durationMs / 1_000}s;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
`;
}
/** Maps a unique key to the animation that needs to be added for that key. */
const animationSet = new Multiset<number>();
/**
* Given a list of frames and a `durationMs` per frame, returns a class that
* will apply the given animation as CSS keyframes. The animation class returned
* is deduped on the `frames-durationMs` key.
*/
export function useAnimation(animation: AnimationType) {
const key = weakHash(JSON.stringify(animation), 0);
const animationRef = useRef(animation);
if (!isEqual(animationRef.current, animation)) {
animationRef.current = animation;
}
const value = animationRef.current;
useEffect(() => {
animationSet.add(key);
// This animation isn't added to the DOM yet, so add it
if (!store.has(key)) {
store.set(key, value);
}
return () => {
animationSet.remove(key);
if (!animationSet.has(key)) {
// Animation no longer exists, so delete it.
store.delete(key);
}
};
}, [key, value]);
return `animation-${key}`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment