Skip to content

Instantly share code, notes, and snippets.

@snuffyDev
Created June 27, 2023 11:11
Show Gist options
  • Save snuffyDev/34c5133b15b7ac595d8a51af4e241ce8 to your computer and use it in GitHub Desktop.
Save snuffyDev/34c5133b15b7ac595d8a51af4e241ce8 to your computer and use it in GitHub Desktop.
import type {
CSSTransform,
KeyframeWithTransform as Keyframe,
KeyframeWithTransform,
} from "./types";
import { isTransform } from "./utils/transform";
import { convertUnits } from "./utils/units";
export interface TweenOptions {
composite?: CompositeOperation;
delay?: number;
direction?: "normal" | "reverse" | "alternate" | "alternate-reverse";
duration: number;
easing?: string;
fill?: "none" | "forwards" | "backwards" | "both" | "auto";
iterations?: number | "infinite";
}
type Point = {
x: number;
y: number;
};
class LRU<K = unknown, V = unknown> extends Map<K, V> {
constructor(private max: number) {
super();
}
override get(key: K): V | undefined {
const entry = super.get(key);
if (entry) {
super.delete(key);
super.set(key, entry);
}
return entry;
}
override set(key: K, value: V): this {
super.delete(key);
if (this.size === this.max) {
this.delete(this.keys().next().value);
}
super.set(key, value);
return this;
}
}
const PATH_CACHE = new LRU<string, Keyframe[] | Point[]>(10);
export class Tween {
private startTime!: number;
protected _keyframes!: KeyframeEffect;
protected animation: Animation | null;
protected keyframes: Keyframe[];
protected options: TweenOptions;
protected srcKeyframes: Keyframe[];
protected target: HTMLElement;
protected tick: (frames: Keyframe[]) => FrameRequestCallback;
protected calcKeyframes() {}
constructor(
target: HTMLElement,
keyframes: Keyframe[],
options: TweenOptions,
) {
this.target = target;
this.srcKeyframes = this.keyframes = [...keyframes];
const temp: Record<CSSTransform, string>[] = [];
for (let idx = 0; idx < keyframes.length; idx++) {
const frame = keyframes[idx];
for (const key in frame) {
console.log(key);
if (Array.isArray(frame[key]) && isTransform(key)) {
for (
let y = 0;
y < (frame[key]! as unknown as string[]).length;
y++
) {
if (!temp[y]) temp[y] = {};
temp[y] = {
...temp[y],
[key]: (frame[key] as unknown as string[])![y]!,
};
}
}
}
}
this.keyframes = this.srcKeyframes.map((clone, index) => {
let transform = "";
const frame = clone;
const ret: Record<string, any> = {};
for (const key in frame) {
if (isTransform(key) || key === "x" || key === "y") {
transform +=
key === "x" || key === "y"
? `translate${key.toUpperCase()}(${convertUnits(
frame[key]?.toString() + "px",
"px",
)}) `
: `${key}(${frame[key]!.toString().split(" ").join(", ")}) `;
continue;
}
ret[key] = frame[key];
}
const hasTempFrame = temp[index];
return {
...ret,
...(hasTempFrame && { ...hasTempFrame }),
};
});
this.options = options;
this.animation = null;
this.tick = (_keyframes) => {
const run = (now: number) => {
if (this.animation?.playState === "finished") return;
if (!this.startTime) this.startTime = now;
let t = (now - this.startTime) * 1;
t /= 1000;
const progress = t / this.duration;
let current = Math.floor(progress);
let wrappedIteration = progress % 1.0;
wrappedIteration === 1 && current--;
this.keyframes?.[wrappedIteration].onComplete?.();
if (current >= frames.length) return;
requestAnimationFrame(run);
};
return run;
};
}
public get duration() {
return this.options.duration;
}
public cancel(): void {
if (this.animation) {
this._keyframes.updateTiming({ duration: 0 });
this.animation.cancel();
this.animation = null;
}
}
public onComplete(callback: () => void): void {
if (this.animation) {
this.animation.onfinish = callback;
}
}
public pause(): void {
if (this.animation) {
this.animation.pause();
}
}
public resume(): void {
if (this.animation) {
this.animation.play();
}
}
public reverse(): void {
if (this.animation) {
this.animation.reverse();
}
}
public start(): void {
this._keyframes = new KeyframeEffect(
this.target,
this.keyframes as never,
this.options as never,
);
this.animation = new Animation(this._keyframes, document.timeline);
this.animation.play();
}
}
export class PathTween extends Tween {
private path: SVGPathElement;
private pathPoints: Point[] = [];
private scaledPoints: Point[] = [];
constructor(
target: HTMLElement,
keyframes: Keyframe[],
options: PathTweenOptions,
) {
super(target, keyframes, options);
this.path =
typeof options.path === "string"
? document.querySelector(options.path)!
: options.path;
if (typeof VisualViewport !== "undefined") {
visualViewport?.addEventListener("resize", () => this.onViewportResize());
}
const factory = this.calculatePoints();
this.keyframes = factory();
}
public override start() {
super.start();
}
private calculatePoints() {
let {
parentBounds,
pathLength,
origin,
parent,
ctm,
end,
pathBounds: boundingClient,
} = this.getBounds();
let segments: Point[] = [];
if (!PATH_CACHE.has(JSON.stringify(this.keyframes))) {
const step = 3;
const pathPoints: SVGPoint[] = Array(Math.floor(pathLength / step));
for (let length = 0, current = 0; length <= pathLength; length += step) {
pathPoints[current++] = this.path.getPointAtLength(length);
}
segments = pathPoints;
PATH_CACHE.set(JSON.stringify(this.keyframes), this.keyframes);
} else {
segments = PATH_CACHE.get(JSON.stringify(this.keyframes))!;
}
// this.pathPoints = segments;
const lerpFrames = [...this.srcKeyframes];
const numLerpFrames = lerpFrames.length;
const viewBox =
parent!.viewBox.baseVal ||
(parent?.hasAttribute("viewBox")
? parent?.getAttribute("viewBox")?.split(" ")
: [0, 0, parentBounds?.width, parentBounds?.height]) ||
[];
const p = {
viewBox,
x: viewBox.x / 1,
y: viewBox.y / 1,
w: boundingClient!.width,
h: boundingClient!.height,
vW: viewBox.width,
vH: viewBox.height,
};
return () => {
const numKeyframes = segments.length;
const scaleX = p.w / p.vW;
const scaleY = p.h / p.vH;
const result = segments.map((point, index, a) => {
const keyframeIndex = Math.floor(
(index / numKeyframes) * numLerpFrames,
);
const frame = lerpFrames[keyframeIndex];
let transform = "";
const { ...val } = Object.fromEntries(
Object.keys(frame)
.map(
(key) =>
typeof frame[key] !== "function" &&
!isTransform(key) &&
key !== "scale" &&
([key, frame[key]] as const),
)
.filter(Boolean) as [any, any],
);
const ret: Record<string, any> = { ...val };
let scale = frame["scale"] ?? 1;
for (const key in frame) {
if (
(isTransform(key) && !key.includes("scale")) ||
key === "x" ||
key === "y"
) {
transform +=
key === "x" || key === "y"
? `translate${key.toUpperCase()}(${convertUnits(
frame[key]?.toString() + "px",
"px",
)}) `
: `${key}(${frame[key]!.toString().split(" ").join(", ")}) `;
continue;
} else {
ret[key] = frame[key];
}
}
const rotation =
(Math.atan2(end.y - origin.y, end.x - origin.x) * 180) / Math.PI;
const translateX = boundingClient.x + (point.x - p.x) * (scaleX || 1);
const translateY =
(point.y - p.y) * (scaleY || 1) + (boundingClient.y - ctm.f * 2);
const originX = -translateX;
const originY = -translateY;
const transformOriginTemplate = `${
-originX / -(scaleX || 1) / -(2 * Math.PI)
}px ${-originY / (scaleY || 1) / (2 * Math.PI)}px`;
// Combine with other transform
return {
...ret,
scale: undefined,
transformOrigin: transformOriginTemplate,
transform: `${transform} translateX(${translateX}px) translateY(${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
};
}) as KeyframeWithTransform[];
return result;
};
}
private getBounds() {
const node = this.path;
const pathLength = node.getTotalLength();
const origin = node.getPointAtLength(0);
const end = node.getPointAtLength(pathLength);
const boundingClient = this.path.getBoundingClientRect();
const ctm = this.path.getScreenCTM()!;
const parent = this.path.ownerSVGElement;
const parentBounds = parent?.getBoundingClientRect();
const screenWidth = parentBounds?.width,
screenHeight = parentBounds?.height;
return {
screenWidth,
screenHeight,
origin,
end,
parent,
pathLength,
ctm,
pathBounds: boundingClient,
parentBounds,
};
}
private onViewportResize() {
if (!this.animation) return;
const currentTime = this.animation?.currentTime;
const currentCb = this.animation?.onfinish!;
const factory = this.calculatePoints();
if (this.animation?.playState === "running") {
requestAnimationFrame(() => {
this._keyframes.updateTiming({
fill: "backwards",
duration: this.duration,
delay: 200,
});
this.keyframes = factory();
this.animation!.currentTime = currentTime!; // Pick-up where we last left off!
this._keyframes.setKeyframes(this.keyframes);
this.animation.onfinish = currentCb;
});
}
}
}
export interface PathTweenOptions extends TweenOptions {
path: string | SVGPathElement;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment