Created
August 27, 2023 23:44
-
-
Save snuffyDev/7ccb464bb00c2028575695ce5c0324f5 to your computer and use it in GitHub Desktop.
TypeScript Animation library
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { EasingFunction, linear } from "./easing"; | |
import { TweenOptions } from "./tween"; | |
import { CSSTransform, KeyframeWithTransform } from "./types"; | |
import { pathStringToSVGPath } from "./utils/path"; | |
import { buildTransform, isTransform } from "./utils/transform"; | |
import * as easings from "./easing"; | |
import { keysWithType } from "./utils/object"; | |
export type PathTweenOptions = TweenOptions & MotionPathOptions; | |
export type Anchor = NonNullable<MotionPathOptions["anchor"]>; | |
export type MotionPathOptions = { | |
path: SVGPathElement | string; | |
anchor?: [x: number, y: number]; | |
rotate?: boolean; | |
}; | |
class Cache<T> extends Map<string, T> { | |
constructor(private max: number) { | |
super(); | |
} | |
public set(key: string, value: T): this { | |
if (this.size >= this.max) { | |
this.delete(this.keys().next().value); | |
} | |
return super.set(key, value); | |
} | |
public get(key: string): T | undefined { | |
const value = super.get(key); | |
if (value) { | |
this.delete(key); | |
this.set(key, value); | |
} | |
return value; | |
} | |
} | |
// create a cache for keyframes to avoid creating the same keyframes multiple times | |
const keyframeCache = new Cache<KeyframeWithTransform[]>(40); | |
const getLastPointInPath = (path: SVGPathElement) => | |
path.getPointAtLength(path.getTotalLength()); | |
export class MotionPath { | |
private _anchor: Exclude<Anchor, string> = [0.5, 0.5]; | |
private _duration = 0; | |
private _length: number; | |
private _path: SVGPathElement; | |
private _rotate: NonNullable<MotionPathOptions["rotate"]>; | |
constructor( | |
private targetElement: HTMLElement, | |
path: SVGPathElement | string, | |
private options: Omit<MotionPathOptions, "path"> & PathTweenOptions, | |
) { | |
this.id = Math.random().toString(36).slice(2); | |
const { anchor = [50, 50], rotate = false } = this.options; | |
if (typeof path === "string") { | |
this._path = pathStringToSVGPath(path); | |
} else { | |
this._path = path; | |
} | |
this._length = this._path.getTotalLength(); | |
this.anchor = anchor; | |
this._rotate = rotate; | |
} | |
public set anchor(newAnchor: Anchor | "auto") { | |
if (newAnchor === "auto") { | |
newAnchor = [0, 0]; | |
} | |
const [anchorX, anchorY] = newAnchor; | |
const { height, width } = this.targetElement.getBoundingClientRect(); | |
const { x, y } = this.path.ownerSVGElement?.getBoundingClientRect() || { | |
x: 0, | |
y: 0, | |
}; | |
this._anchor = [x + width * 0.5 - anchorX, y + height / 2 - anchorY]; | |
} | |
public get path(): SVGPathElement { | |
return this._path; | |
} | |
public set path(value: SVGPathElement) { | |
this._path = value; | |
} | |
public set target(newTarget: HTMLElement) { | |
this.targetElement = newTarget; | |
} | |
public build( | |
frames: KeyframeWithTransform[], | |
easing?: EasingFunction, | |
): KeyframeWithTransform[] { | |
const parent = this._path.ownerSVGElement!; | |
const boundingClient = this._path.getBoundingClientRect(); | |
const parentBounds = parent.getBoundingClientRect(); | |
const viewBox = | |
parent.viewBox.baseVal || | |
(parent.hasAttribute("viewBox") | |
? parent.getAttribute("viewBox")?.split(" ") | |
: [0, 0, parentBounds?.width, parentBounds?.height]) || | |
[]; | |
let step = 10; | |
const pathPoints: SVGPoint[] = Array(~~(this._length >>> step)); | |
for (let length = 0, current = 0; length < this._length - 1; ) { | |
const point = this._path.getPointAtLength(length); | |
if (!point) { | |
pathPoints[current++] = getLastPointInPath(this.path); | |
break; | |
} | |
pathPoints[current++] = point; | |
length += step; | |
if (length >= this._length) { | |
pathPoints[current++] = getLastPointInPath(this.path); | |
} | |
} | |
console.log({ pathPoints, length: pathPoints.length }); | |
const p = { | |
viewBox, | |
x: viewBox.x / 2, | |
y: viewBox.y / 2, | |
w: boundingClient!.width, | |
h: boundingClient!.height, | |
vW: viewBox.width, | |
vH: viewBox.height, | |
}; | |
const scaleX = Math.fround(p.w / p.vW); | |
const scaleY = Math.fround(p.h / p.vH ?? 1); | |
const key = JSON.stringify({ scaleX, scaleY, frames, id: this.id }); | |
if (keyframeCache.has(key)) { | |
return keyframeCache.get(key)!; | |
} | |
const final = interpolateKeyframes({ | |
boundingClient, | |
frames, | |
pathPoints: pathPoints.filter(Boolean), | |
p, | |
anchor: this._anchor, | |
scaleX, | |
scaleY, | |
rotate: this._rotate, | |
easingGenerator: | |
typeof easing === "object" | |
? easing | |
: typeof easing === "string" | |
? easing in easings | |
? easings[easing] | |
: linear | |
: linear, | |
}); | |
keyframeCache.set(key, final); | |
return final; | |
} | |
} | |
const numberRegex = /[-+]?\d*\.?\d+/g; | |
function interpolateNumbersInString( | |
str: string, | |
easing: EasingFunction, | |
): string { | |
return str.replace(numberRegex, (match) => { | |
const oldValue = parseFloat(match); | |
const newValue = easing(oldValue); | |
return `${newValue}`; | |
}); | |
} | |
function interpolateMultiNumericalProperty( | |
fromValue: string, | |
toValue: string, | |
progress: number, | |
): string { | |
const fromValues = fromValue.split(/\s*,\s*|\s+/).map(parseFloat); | |
const toValues = toValue.split(/\s*,\s*|\s+/).map(parseFloat); | |
const interpolatedValues = fromValues.map((fromVal, index) => { | |
const toVal = toValues[index] || fromVal; | |
return fromVal + (toVal - fromVal) * progress; | |
}); | |
// Combine the interpolated values back into a string | |
return interpolatedValues.join(fromValue.includes(",") ? ", " : " "); | |
} | |
function hasMultiNumericalValues(value: any): boolean { | |
if (typeof value === "string") { | |
const numericalValues = value.match(/[-\d.]+/g); | |
return !!numericalValues && numericalValues.length >= 2; | |
} | |
return false; | |
} | |
function interpolateKeyframes({ | |
boundingClient, | |
frames, | |
pathPoints, | |
p, | |
anchor, | |
scaleX, | |
scaleY, | |
rotate, | |
easingGenerator, | |
}: { | |
boundingClient: DOMRect; | |
easingGenerator: EasingFunction | easings.EasingFactory; | |
frames: KeyframeWithTransform[]; | |
pathPoints: SVGPoint[]; | |
p: { | |
viewBox: DOMRect; | |
x: number; | |
y: number; | |
w: number; | |
h: number; | |
vW: number; | |
vH: number; | |
}; | |
anchor: Anchor; | |
scaleX: number; | |
scaleY: number; | |
rotate: boolean; | |
}): KeyframeWithTransform[] { | |
const easing = | |
typeof easingGenerator === "object" | |
? easingGenerator.calc | |
: easingGenerator; | |
const totalFrames = frames.length; | |
const step = 1 / (pathPoints.length - 1); | |
const keyframes = Array.from(pathPoints, function (point, index) { | |
const t = index * step; | |
let keyframeIndex = Math.floor(easing(t) * (totalFrames - 1)); | |
const nextKeyframeIndex = Math.floor(easing(index * step) * totalFrames); | |
const frame = frames[keyframeIndex]; | |
const nextFrame = frames[nextKeyframeIndex]; | |
let { scale = 1 } = frame; | |
// Handle the last frame separately | |
if (index === pathPoints.length - 1) { | |
keyframeIndex = totalFrames - 1; | |
} | |
const offset = index === pathPoints.length - 1 ? 1 : easing(t); | |
const temp = {} as NonNullable<typeof frame>; | |
let transform = ` `; | |
const keys = keysWithType( | |
frame as KeyframeWithTransform & { scale?: number }, | |
); | |
for (const key of keys) { | |
if (key === "easing" || key === "scale") continue; | |
if (isTransform(key) || key.includes("scale")) { | |
transform += interpolateNumbersInString( | |
buildTransform(key as CSSTransform, frame[key] as string) + " ", | |
easing, | |
); | |
} | |
if (!(key in frame) && key in nextFrame) { | |
const prevValue = frame[key]; | |
const nextValue = nextFrame[key as keyof typeof frame]; | |
if (hasMultiNumericalValues(frame[key])) { | |
temp[key] = interpolateMultiNumericalProperty( | |
prevValue as never, | |
nextValue as never, | |
offset, | |
) as never; | |
} else if ( | |
typeof prevValue === "number" && | |
typeof nextValue === "number" | |
) { | |
// Interpokeyframeslate numeric values | |
temp[key] = (prevValue + offset * (nextValue - prevValue)) as never; | |
} else if ( | |
typeof prevValue === "string" && | |
typeof nextValue === "string" | |
) { | |
if (hasMultiNumericalValues(frame[key])) { | |
setMultiNumValues(temp, key, prevValue, nextValue, offset); | |
} else { | |
if (hasMultiNumericalValues(frame[key])) { | |
setMultiNumValues(temp, key, prevValue, nextValue, offset); | |
} | |
// Interpolate numbers in strings | |
else | |
temp[key as keyof typeof frame] = interpolateNumbersInString( | |
prevValue, | |
easing, | |
) as never; | |
} | |
} else { | |
if (hasMultiNumericalValues(frame[key])) { | |
setMultiNumValues(temp, key, prevValue, nextValue, offset); | |
} else { | |
// Use the next value | |
temp[key as keyof typeof frame] = nextValue as never; | |
} | |
} | |
} | |
// Look ahead to the next keyframe that contains the property | |
let nextKeyframeIndex = index + 1; | |
while (nextKeyframeIndex < pathPoints.length) { | |
const nextKeyframe = | |
frames[Math.floor(easing(nextKeyframeIndex * step) * totalFrames)]; | |
if (nextKeyframe && key in nextKeyframe) { | |
const nextValue = nextKeyframe[key as keyof typeof nextKeyframe]; | |
if (typeof temp[key as keyof typeof temp] === "undefined") { | |
temp[key as keyof typeof temp] = nextValue as never; | |
} | |
break; | |
} | |
nextKeyframeIndex++; | |
} | |
} | |
scale = | |
scale < 1 || (scale > -1 && scale < 1) ? 1 + Math.abs(scale) : scale; | |
const [anchorX, anchorY] = anchor; | |
const p0 = pathPoints.at(index >= 1 ? index - 1 : 0); | |
const p1 = pathPoints.at(index + 1) ?? point; | |
const translateX = | |
boundingClient.left - anchorX + (point.x - p.x) * (+scaleX || 1); | |
const translateY = | |
boundingClient.top - anchorY + (point.y - p.y) * (+scaleY || 1); | |
const autoRotate = rotate | |
? (Math.atan2(p1.y - p0!.y, p1.x - p0!.x) * 180) / Math.PI | |
: 0; | |
transform = `${ | |
frame.transform ?? "" | |
}translateX(${translateX}px) translateY(${translateY}px) scale(${ | |
scale * easing(1 - t) + scale | |
}) ${ | |
rotate ? `rotate(${easing(1 - t) * autoRotate}deg)` : "" | |
} ${transform}`; | |
return { | |
...temp, | |
...(scale && {}), | |
transform: `${transform}`, | |
offset: Math.min(Math.max(offset, 0), 1), | |
} as KeyframeWithTransform; | |
}); | |
return keyframes; | |
function setMultiNumValues( | |
temp: NonNullable<KeyframeWithTransform>, | |
key: string, | |
prevValue: string, | |
nextValue: string, | |
offset: number, | |
) { | |
temp[key as keyof typeof temp] = interpolateMultiNumericalProperty( | |
prevValue, | |
nextValue, | |
offset, | |
) as never; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { PathTweenOptions } from "./motionPath"; | |
import { tween, TweenOptions } from "./tween"; | |
import { KeyframeWithTransform } from "./types"; | |
import { is } from "./utils/is"; | |
export interface TimelineOptions { | |
defaults: TweenOptions & { | |
motionPath?: PathTweenOptions; | |
}; | |
paused?: boolean; | |
repeat?: number; | |
} | |
const microtask = (() => { | |
const items: (() => void)[] = []; | |
let frameId: number | undefined; | |
return (callback: () => void) => { | |
if (frameId) { | |
cancelAnimationFrame(frameId); | |
frameId = undefined; | |
} | |
items.push(callback); | |
if (!frameId) { | |
frameId = requestAnimationFrame(function loop() { | |
while (items.length > 0) { | |
const item = items.shift(); | |
if (item) item(); | |
} | |
}); | |
} | |
}; | |
})(); | |
class Timeline { | |
private tweens: ReturnType<typeof tween>[] = []; | |
private state: "idle" | "running" | "paused" | "finished" = "idle"; | |
constructor(private defaultOptions: TimelineOptions) { | |
visualViewport?.addEventListener("resize", () => { | |
for (const tween of this.tweens) { | |
microtask(() => { | |
tween.onResize(); | |
}); | |
} | |
}); | |
} | |
private async playTweens() { | |
const totalPlayCount = | |
(is<number>(this.defaultOptions.repeat, "number") && | |
this.defaultOptions.repeat) || | |
1; | |
for (let playCount = 0; playCount < totalPlayCount; playCount++) { | |
for (const tween of this.tweens) { | |
if (this.state === "paused") return; | |
tween.play(); | |
await tween.finished().catch((e) => { | |
console.error(`Failed to play tween: ${e}`); | |
}); | |
tween.cancel(); | |
} | |
} | |
this.state = "finished"; | |
} | |
play() { | |
if (this.state !== "running") { | |
this.state = "running"; | |
this.playTweens(); | |
} | |
} | |
pause() { | |
this.state = "paused"; | |
} | |
kill() { | |
for (const tween of this.tweens) { | |
tween.cancel(); | |
} | |
this.state = "finished"; | |
} | |
to( | |
target: HTMLElement, | |
keyframes: KeyframeWithTransform[], | |
options: PathTweenOptions | TweenOptions, | |
) { | |
const { defaults } = this.defaultOptions; | |
const { motionPath, ...rest } = defaults; | |
const _tween = tween(target, keyframes, { | |
...rest, | |
...((!!motionPath || "path" in options) && { ...motionPath }), | |
...options, | |
}); | |
this.tweens.push(_tween); | |
if (this.state === "idle" && !this.defaultOptions.paused) { | |
this.play(); | |
} | |
return this; | |
} | |
} | |
export type { Timeline }; | |
export function timeline(defaultOptions: TimelineOptions): Timeline { | |
return new Timeline(defaultOptions); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { EasingFactory, EasingFunction, linear } from "./easing"; | |
import { MotionPath, PathTweenOptions } from "./motionPath"; | |
import { createPlaybackController } from "./tween/controller"; | |
import type { KeyframeWithTransform } from "./types"; | |
import { is } from "./utils/is"; | |
import { debounce } from "./utils/throttle"; | |
// @ts-expect-error idk what broke but smth did | |
export interface TweenOptions extends KeyframeEffectOptions { | |
composite?: CompositeOperation; | |
delay?: number; | |
direction?: "normal" | "reverse" | "alternate" | "alternate-reverse"; | |
duration: number; | |
fill?: "none" | "forwards" | "backwards" | "both"; | |
iterations?: number | "infinite"; | |
playbackRate?: number; | |
easing?: string | EasingFactory; | |
} | |
export type CompositeOperation = | |
| "replace" | |
| "add" | |
| "accumulate" | |
| "auto" | |
| "none"; | |
/** | |
* Helper function for getting the CSS easing function from an easing factory or from a string | |
* | |
* If the easing is a string, it's assumed to be a CSS easing function and is returned as-is. | |
* @internal | |
* @param easing Easing function or string | |
* @returns CSS easing function (string) | |
*/ | |
const getEasingFunctionName = (easing: string | EasingFactory): string => { | |
if (is<EasingFactory>(easing, (thing) => typeof thing === "object")) { | |
return easing.css; | |
} | |
return easing; | |
}; | |
const createKeyframeEffect = ( | |
element: Element, | |
keyframes: KeyframeWithTransform[], | |
options: TweenOptions, | |
): KeyframeEffect => { | |
const effect = new KeyframeEffect( | |
element, | |
keyframes as Keyframe[], | |
options as KeyframeAnimationOptions, | |
); | |
return effect; | |
}; | |
const getPathElementFromOptions = ( | |
options: PathTweenOptions, | |
): SVGPathElement => { | |
if ( | |
is<PathTweenOptions>( | |
options.path, | |
(thing) => thing instanceof SVGPathElement, | |
) | |
) { | |
return options.path as SVGPathElement; | |
} | |
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); | |
path.setAttribute("d", options.path as string); | |
return path; | |
}; | |
const pathTween = ( | |
element: Element, | |
keyframes: KeyframeWithTransform[], | |
options: PathTweenOptions, | |
) => { | |
// TODO: use this somehow | |
let easing: EasingFunction | EasingFactory = | |
typeof options.easing === "object" ? options.easing : linear; | |
let invalidated = false; | |
if (options.easing) { | |
const easingFactory = options.easing; | |
if (typeof easingFactory === "object") { | |
options.easing = easingFactory.css; | |
easing = easingFactory.calc; | |
} | |
} | |
const motionPath = new MotionPath(element as HTMLElement, options.path, { | |
...options, | |
}); | |
const buildKeyframes = () => { | |
motionPath.anchor = options.anchor ? options.anchor : "auto"; | |
motionPath.path = getPathElementFromOptions(options); | |
return motionPath.build(keyframes, easing as never); | |
}; | |
const effect = createKeyframeEffect(element, buildKeyframes(), { | |
...options, | |
easing: options.easing ? getEasingFunctionName(options.easing) : "linear", | |
}); | |
const animation = new Animation(effect); | |
const playbackController = createPlaybackController(animation); | |
const onResize = debounce(() => { | |
if (animation.playState !== "running") { | |
invalidated = true; | |
return; | |
} | |
effect.setKeyframes(buildKeyframes() as Keyframe[]); | |
}); | |
return { | |
...playbackController, | |
play: () => { | |
playbackController.play(); | |
if (invalidated) { | |
onResize().finally(() => { | |
invalidated = false; | |
}); | |
} | |
}, | |
onResize, | |
get config() { | |
return options; | |
}, | |
}; | |
}; | |
export const tween = ( | |
element: Element, | |
keyframes: KeyframeWithTransform[], | |
options: TweenOptions | PathTweenOptions, | |
) => { | |
if ("path" in options) { | |
return pathTween(element, keyframes, options); | |
} | |
const effect = createKeyframeEffect(element, keyframes, options); | |
const animation = new Animation(effect, document.timeline); | |
const playbackController = createPlaybackController(animation); | |
return { | |
...playbackController, | |
onResize: () => { | |
return; | |
}, | |
get config() { | |
return options; | |
}, | |
}; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment