Skip to content

Instantly share code, notes, and snippets.

@snuffyDev
Last active July 21, 2023 21:04
Show Gist options
  • Save snuffyDev/4bffa735faa41c477708272f24981194 to your computer and use it in GitHub Desktop.
Save snuffyDev/4bffa735faa41c477708272f24981194 to your computer and use it in GitHub Desktop.
Animation Library made with TypeScript
import { TweenOptions } from "./tween";
import { KeyframeWithTransform } from "./types";
import { pathStringToSVGPath } from "./utils/path";
import { buildTransform, isTransform } from "./utils/transform";
export type PathTweenOptions = TweenOptions & MotionPathOptions;
export type Anchor = NonNullable<MotionPathOptions["anchor"]>;
export type MotionPathOptions = {
path: SVGPathElement | string;
anchor?: [x: number, y: number];
rotate?: boolean;
};
export class MotionPath {
private _anchor: Exclude<Anchor, string> = [0.5, 0.5];
private _length: number;
private _path: SVGPathElement;
private _rotate: NonNullable<MotionPathOptions["rotate"]>;
constructor(
private targetElement: HTMLElement,
path: SVGPathElement | string,
private options: Omit<MotionPathOptions, "path">,
) {
const { anchor = [0.5, 0.5], 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 = [50, 50];
}
const [anchorX, anchorY] = newAnchor;
const { height, width, x, y } = this.targetElement.getBoundingClientRect();
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[]) {
const boundingClient = this._path.getBoundingClientRect();
const parent = this._path.ownerSVGElement!;
const parentBounds = this._path.ownerSVGElement!.getBoundingClientRect();
const viewBox =
parent.viewBox.baseVal ||
(parent.hasAttribute("viewBox")
? parent.getAttribute("viewBox")?.split(" ")
: [0, 0, parentBounds?.width, parentBounds?.height]) ||
[];
let step = 2;
const pathPoints: SVGPoint[] = Array(Math.floor(this._length / step));
for (let length = 0, current = 0; length < this._length - 1; ) {
if (current >= this._length) break;
pathPoints[current++] = this._path.getPointAtLength(length);
if (length + step > this._length) {
console.log({ current, length, totalLength: this._length });
step = Math.floor(this._length - length);
console.log({ newStep: step });
} else {
length += step;
}
}
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 = p.w / p.vW;
const scaleY = p.h / p.vH ?? 1;
return interpolateKeyframes({
target: this.targetElement,
boundingClient,
frames,
pathPoints,
p,
anchor: this._anchor,
scaleX,
scaleY,
rotate: this._rotate,
});
}
}
function interpolateKeyframes({
target,
boundingClient,
frames,
pathPoints,
p,
anchor,
scaleX,
scaleY,
rotate,
}: {
target: HTMLElement;
boundingClient: DOMRect;
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 fullFrame = frames.reduce((acc, curr) => {
const keys = Object.keys(curr);
for (const key of keys) {
const prop = curr[key as keyof typeof curr];
if (prop === undefined || prop === null) continue;
if (key in acc && prop !== acc[key]) {
acc[key] = prop;
}
}
return acc;
}, {} as KeyframeWithTransform);
const lastSeenIndex = new Set<number>([]);
let lastSeenTransform = "";
return Array.from(pathPoints, (point, index) => {
// const point = pathPoints[index];
let firstSeenIndex = false;
const keyframeIndex = Math.floor(
(index / pathPoints.length) * frames.length,
);
if (!lastSeenIndex.has(keyframeIndex)) {
lastSeenIndex.add(keyframeIndex);
firstSeenIndex = true;
}
const frame = frames[keyframeIndex];
const temp = {} as Partial<typeof frame>;
let transform = `${frame.transform ?? ""}`;
let { scale = 1 } = frame as { scale: number };
scale =
scale < 1 || (scale > -1 && scale < 1) ? 1 + Math.abs(scale) : scale;
for (const [key, value] of Object.entries(frame)) {
if (value === null || value === undefined) continue;
if (isTransform(key)) {
if (key.includes("scale")) continue;
transform += buildTransform(key, value as string);
continue;
}
if (key.includes("scale")) continue;
if (value !== undefined && value !== null) temp[key] = value;
}
const [anchorX, anchorY] = anchor;
const p0 = pathPoints.at(index >= 1 ? index - 1 : 0);
const p1 = pathPoints.at(index + 1) ?? point;
const translateX =
boundingClient.x - anchorX + (point.x - p.x) * (+scaleX || 1);
const translateY =
boundingClient.y - anchorY + (point.y - p.y) * (+scaleY || 1);
0;
const autoRotate = rotate
? (Math.atan2(p1.y - p0!.y, p1.x - p0!.x) * 180) / Math.PI
: 0;
transform += ` translateX(${translateX}px) translateY(${translateY}px) scale(${scale}) rotate(${autoRotate}deg)`;
if (firstSeenIndex && lastSeenTransform) {
lastSeenTransform = transform;
} else {
lastSeenTransform = transform;
}
return {
...(firstSeenIndex && fullFrame),
...(firstSeenIndex && temp),
...(scale && {}),
// transformOrigin: transformOriginTemplate,
transform: `${transform}`,
} as KeyframeWithTransform;
}) as KeyframeWithTransform[];
}
import { PathTween, PathTweenOptions, Tween, TweenOptions } from "./tween";
import { KeyframeWithTransform } from "./types";
import { skipFirstInvocation } from "./utils/function";
import { is } from "./utils/is";
import { throttle } from "./utils/throttle";
export interface TimelineOptions {
defaults: TweenOptions & {
motionPath?: PathTweenOptions;
};
paused?: boolean;
repeat?: number;
}
class Timeline {
private _currentTime: number = 0;
private _endTime: number = 0;
private _progress: number = 0;
private frameRequest: number | undefined;
private state: AnimationPlayState = "idle";
private tick = (): void => {
if (this.state === "paused") return;
this._endTime = this.tweens.reduce((acc, tween) => {
const duration =
is<number>(tween.config?.iterations, "number") &&
tween.config.iterations > 1
? Math.floor(tween.duration / tween.config.iterations)
: tween.duration;
return acc + duration;
}, 0);
let canPlayNextTween = false;
const run = async (now: number): Promise<void> => {
while (true) {
await new Promise((resolve) => requestAnimationFrame(resolve));
if (this.state === "finished") {
this._endTime = now;
this.frameRequest = undefined;
break;
}
this._currentTime = now;
if (this.state === "running") {
if (this._progress >= this.tweens.length) {
if (
is<number>(
this.defaultOptions.repeat,
"number",
(repeat) => repeat > 0,
)
) {
if (this.defaultOptions.repeat > 1) {
this._progress = 0;
this._currentTime = 0;
} else {
this.state = "finished";
}
} else {
this.state = "finished";
}
}
const tween = this.tweens[this._progress];
if (tween) {
if (!this._progress || canPlayNextTween) {
canPlayNextTween = false;
requestAnimationFrame(() => {
tween.start();
});
await tween.finished;
if (this.state === "running") {
if (
is<number>(tween.config?.iterations, "number") &&
tween.config.iterations > 1
) {
canPlayNextTween = true;
this._progress++;
continue;
} else {
canPlayNextTween = true;
this._progress++;
continue;
}
}
}
} else {
this._progress = 0;
}
}
}
};
this.frameRequest = requestAnimationFrame(run);
};
private tweens: (Tween | PathTween)[] = [];
constructor(private defaultOptions: TimelineOptions) {
const throttledResize = throttle<[Event]>(
skipFirstInvocation(() => this.handleResize()),
48,
).bind(this);
visualViewport?.addEventListener("resize", throttledResize);
}
public get currentTime(): number {
return this._currentTime;
}
public get endTime(): number {
return this._endTime;
}
public get progress(): number {
return this._progress;
}
public set progress(value: number) {
this._progress = value;
}
public kill(): void {
for (const tween of this.tweens) {
tween.cancel();
}
this.state = "finished";
if (this.frameRequest) cancelAnimationFrame(this.frameRequest);
}
public pause(): void {
this.state = "paused";
}
public play(): void {
this.state = "running";
this.frameRequest = requestAnimationFrame(this.tick);
}
public to(
target: HTMLElement,
keyframes: KeyframeWithTransform[],
options: PathTweenOptions | TweenOptions,
): Timeline {
const { defaults } = this.defaultOptions;
const { motionPath, ...rest } = defaults;
const BaseTween = "path" in options ? PathTween : Tween;
const tween = new BaseTween(target, keyframes, {
...rest,
...((!!motionPath || "path" in options) && { ...motionPath }),
...options,
});
this.tweens.push(tween);
return this;
}
private handleResize(): void {
this.tweens.forEach((tween) => {
// if (this.state == "idle") return;
if (tween instanceof PathTween) {
// @ts-expect-error internal method
if ("onViewportResize" in tween) tween.onViewportResize();
}
});
}
}
export type { Timeline };
export function timeline(defaultOptions: TimelineOptions): Timeline {
return new Timeline(defaultOptions);
}
import { Anchor, MotionPath, PathTweenOptions } from "./motionPath";
import type { KeyframeWithTransform } from "./types";
import { is } from "./utils/is";
import { TRANSFORM_KEYS } from "./utils/transform";
export type { PathTweenOptions };
export type EasingFunction = (t: number) => number;
export interface TweenOptions {
composite?: CompositeOperation;
delay?: number;
direction?: "normal" | "reverse" | "alternate" | "alternate-reverse";
duration: number;
easing?: string | EasingFunction;
fill?: "none" | "forwards" | "backwards" | "both" | "auto";
iterations?: number | "infinite";
playbackRate: number;
}
const interpolationProperties = new Set<keyof KeyframeWithTransform>([
"x",
"y",
...TRANSFORM_KEYS,
]);
function interpolateKeyframes(
keyframes: KeyframeWithTransform[],
easing: EasingFunction | string,
): KeyframeWithTransform[] {
const numKeyframes = keyframes.length;
const numInterpolatedKeyframes = Math.max(1, numKeyframes - 1);
const handleEase = (progress: number) =>
typeof easing === "function" ? easing(progress) : progress;
const interpolatedKeyframes = Array.from(
{ length: numInterpolatedKeyframes },
(_, i) => {
const progress = i / numInterpolatedKeyframes;
const keyframeIndex = Math.floor(
handleEase(progress) * (numKeyframes - 1),
);
const keyframeA = keyframes[keyframeIndex];
const keyframeB = keyframes[keyframeIndex + 1];
const interpolatedKeyframe: KeyframeWithTransform = {};
for (const key in keyframeA) {
const valueA = keyframeA[key];
const valueB = keyframeB?.[key];
if (
typeof valueB !== "number" &&
typeof valueA !== "number" &&
!valueA &&
!valueB
)
continue;
if (interpolationProperties.has(key as never)) {
interpolatedKeyframe[key] = (valueA! ?? valueB!) as never;
} else {
interpolatedKeyframe[key] = valueA ?? valueB;
}
}
return interpolatedKeyframe;
},
);
return interpolatedKeyframes;
}
export type PlayState = "idle" | "running" | "paused" | "finished";
export class BaseAnimation {
private _currentTime = 0;
private _playState: PlayState;
private frameRequest: number | undefined;
protected options: TweenOptions;
protected progress: number;
protected keyframes: KeyframeWithTransform[];
private resolveFinishedPromise: (() => void) | null;
protected srcKeyframes: KeyframeWithTransform[];
protected startTime: number | null;
protected target: HTMLElement;
protected tick = (timestamp: number): void => {
if (!this.startTime) {
this.startTime = timestamp;
}
this._currentTime = timestamp;
const elapsed = this.currentTime - this.startTime;
const { duration } = this.options;
const totalDuration = duration * 1;
const iterations = is<number>(this.options.iterations, "number")
? Math.max(1, this.options.iterations)
: this.options.iterations === "infinite"
? Infinity
: 1;
const totalDurationWithIterations = totalDuration * iterations;
const iterationDuration = totalDurationWithIterations / iterations;
const progress = elapsed / iterationDuration;
const currentIteration = Math.floor(elapsed / iterationDuration);
this.progress = progress;
this.iteration = currentIteration;
const currentKeyframe =
this.srcKeyframes[
~~(
(this.srcKeyframes.length * progress + 1) *
(currentIteration || 1)
) % this.srcKeyframes.length
];
if (currentKeyframe && typeof currentKeyframe.onComplete === "function") {
currentKeyframe.onComplete();
}
if (currentIteration >= iterations) {
this.playState = "finished";
if (this.resolveFinishedPromise) {
this.resolveFinishedPromise();
this.playState = "finished";
}
return;
} else {
this.frameRequest = requestAnimationFrame(this.tick);
}
};
public iteration: number = 0;
constructor(
target: HTMLElement,
keyframes: KeyframeWithTransform[],
options: TweenOptions,
) {
this.target = target;
this.srcKeyframes = keyframes;
this.keyframes = interpolateKeyframes(
keyframes,
options.easing || "linear",
);
this.options = options;
this.startTime = null;
this._currentTime = 0;
this.progress = 0;
this._playState = "idle";
this.frameRequest = undefined;
this.resolveFinishedPromise = null;
}
public get currentTime(): number {
return this._currentTime;
}
public set currentTime(value: number) {
this._currentTime = value;
}
public get duration(): number {
return this.options.duration as number;
}
public get playState(): PlayState {
return this._playState;
}
public get playbackRate(): number {
return this.options.playbackRate!;
}
protected set playState(value: PlayState) {
this._playState = value;
}
public cancel(): void {
if (this.playState === "idle" || this.playState === "finished") return;
this._playState = "idle";
if (this.frameRequest) cancelAnimationFrame(this.frameRequest);
this.frameRequest = undefined;
this.startTime = null;
this._currentTime = 0;
this.progress = 0;
}
public finish(): Promise<void> {
if (this.playState === "finished") return Promise.resolve();
return new Promise((resolve) => {
this.resolveFinishedPromise = resolve;
});
}
public pause(): void {
if (this.playState !== "running") return;
this._playState = "paused";
if (this.frameRequest) cancelAnimationFrame(this.frameRequest);
this.frameRequest = undefined;
}
public play(): void {
if (this.playState === "running") return;
if (this.playState === "finished" || this.playState === "idle") {
this.progress = 0;
this.startTime = null;
}
this._playState = "running";
this.frameRequest = requestAnimationFrame(this.tick);
}
public stop(): void {
if (this.playState === "idle" || this.playState === "finished") return;
this._playState = "idle";
if (this.frameRequest) cancelAnimationFrame(this.frameRequest);
this.frameRequest = undefined;
this.startTime = null;
this.currentTime = 0;
this.progress = 0;
}
}
export class Tween extends BaseAnimation {
protected animation: Animation | null = null;
protected easing: EasingFunction | string = "linear";
protected keyframeEffect!: KeyframeEffect;
protected keyframes: KeyframeWithTransform[];
protected onCompleteHandlers: (() => void)[] = [];
protected progress: number = 0;
protected srcKeyframes: KeyframeWithTransform[];
public frames = {
set: (...frames: KeyframeWithTransform[]) => {
this.keyframes = interpolateKeyframes(
frames,
this.options.easing ? this.options.easing : "linear",
);
this.keyframeEffect.setKeyframes(this.keyframes as Keyframe[]);
this.animation = new Animation(this.keyframeEffect);
},
get: () => {
return this.keyframes;
},
};
constructor(
protected target: HTMLElement,
keyframes: KeyframeWithTransform[],
protected options: TweenOptions,
) {
const _keyframes = interpolateKeyframes(
keyframes,
options.easing ? options.easing : "linear",
);
super(target, keyframes, options);
if (this.options.easing) {
this.easing = this.options.easing;
this.options.easing = "linear";
}
this.keyframes = _keyframes;
this.srcKeyframes = [...keyframes];
this.keyframeEffect = new KeyframeEffect(
this.target,
_keyframes as Keyframe[],
this.options as KeyframeAnimationOptions,
);
this.animation = new Animation(this.keyframeEffect);
this.pause();
}
public get config(): TweenOptions {
return this.options;
}
public get duration(): number {
return this.options.duration;
}
public get finished() {
return this.animation!.finished;
}
public cancel(): void {
this.playState = "finished";
if (this.animation) {
this.animation.commitStyles();
// @ts-expect-error it's fine
this.keyframeEffect = undefined;
this.animation.cancel();
}
}
public dispose(): void {
this.playState = "finished";
this.onCompleteHandlers.forEach((callback) => {
this.animation?.removeEventListener("finish", callback);
this.animation?.removeEventListener("cancel", callback);
this.animation?.removeEventListener("remove", callback);
});
}
public onComplete(callback: () => void): () => void {
this.onCompleteHandlers.push(callback);
if (this.animation) {
this.animation.addEventListener("finish", callback);
}
return () => {
this.onCompleteHandlers = this.onCompleteHandlers.filter(
(cb) => cb !== callback,
);
this.animation?.removeEventListener("finish", callback);
};
}
public pause(): void {
if (this.animation) {
this.playState = "paused";
this.animation.pause();
}
}
public resume(): void {
if (this.animation) {
this.playState = "running";
this.animation.play();
}
}
public reverse(): void {
if (this.animation) {
this.animation.reverse();
}
}
public setProgress(time: number): void {
if (this.animation) {
this.animation.currentTime = time as CSSNumberish;
this.currentTime = time;
}
}
public start(): void {
if (this.playState === "running" || this.srcKeyframes.length === 0) return;
this.progress = 0;
this.startTime = 0;
if (!this.keyframeEffect) {
this.keyframeEffect = new KeyframeEffect(
this.target,
this.keyframes as Keyframe[],
this.options as KeyframeAnimationOptions,
);
}
if (!this.animation) {
this.animation = new Animation(this.keyframeEffect);
}
if (this.playState === "paused") {
this.resume();
} else {
this.animation.play();
}
const handler = this.onComplete(() => {
handler();
this.cancel();
});
this.tick(performance.now());
}
}
export class PathTween extends Tween {
protected builder: MotionPath;
protected path: SVGPathElement;
constructor(
target: HTMLElement,
keyframes: KeyframeWithTransform[],
options: PathTweenOptions,
) {
super(target, keyframes, options);
const { anchor, rotate } = options;
this.path =
typeof options.path === "string"
? document.querySelector(options.path)!
: options.path;
this.builder = new MotionPath(target, this.path, { anchor, rotate });
if (anchor) this.builder.anchor = anchor as NonNullable<Anchor>;
this.keyframes = this.builder.build(this.srcKeyframes);
this.frames.set(...this.keyframes);
}
public build(path: SVGPathElement = this.path): void {
this.path = path;
this.keyframes = this.builder.build(this.srcKeyframes);
}
protected onViewportResize(): void {
this.builder.path = this.path;
this.keyframes = this.builder.build(this.srcKeyframes);
if (!this.animation) {
this.keyframeEffect = new KeyframeEffect(
this.target,
this.keyframes as Keyframe[],
this.options as never,
);
this.keyframeEffect.setKeyframes(this.keyframes as Keyframe[]);
return;
}
const currentState = this.animation.playState;
const currentTime = this.animation.currentTime;
const currentCb = this.animation.onfinish!;
if (this.animation) {
this.animation?.cancel();
this.animation.currentTime = currentTime;
this.keyframeEffect.setKeyframes(this.keyframes as Keyframe[]);
this.setProgress(
(currentTime as number) ?? document.timeline.currentTime ?? 0,
);
this.animation.onfinish = currentCb;
if (currentState === "running") {
this.resume();
}
}
}
}
interface Point {
x: number;
y: number;
}
export const scalePoints = (
points: Point[],
targetWidth: number,
targetHeight: number,
): Point[] => {
// Find the maximum x and y values of the points
let maxX = Number.MIN_VALUE;
let maxY = Number.MIN_VALUE;
for (const point of points) {
if (point.x > maxX) {
maxX = point.x;
}
if (point.y > maxY) {
maxY = point.y;
}
}
// Calculate the scaling factors
const scaleX = targetWidth / maxX;
const scaleY = targetHeight / maxY;
// Scale the points
const scaledPoints: Point[] = [];
for (const point of points) {
const scaledX = point.x * scaleX;
const scaledY = point.y * scaleY;
scaledPoints.push({ x: scaledX, y: scaledY });
}
return scaledPoints;
};
export const scaleSinglePoint = (
point: Point,
targetWidth: number,
targetHeight: number,
): Point => {
// Find the maximum x and y values of the points
let maxX = Number.MIN_VALUE;
let maxY = Number.MIN_VALUE;
if (point.x > maxX) {
maxX = point.x;
}
if (point.y > maxY) {
maxY = point.y;
}
// Calculate the scaling factors
const scaleX = targetWidth / maxX;
const scaleY = targetHeight / maxY;
const scaledX = point.x * scaleX;
const scaledY = point.y * scaleY;
return { x: scaledX, y: scaledY };
};
export const convertSvgPathToPoints = (
svgPath: string | SVGPathElement,
): Point[] => {
const step = 5; // Increase or decrease this value to control the precision
// converts a path string into a path element that
// we can read points from
if (typeof svgPath === "string") {
const pathString = svgPath;
svgPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
svgPath.setAttribute("d", pathString);
}
const totalLength = svgPath.getTotalLength();
const points: DOMPoint[] = Array(Math.floor(totalLength / step));
for (let length = 0, current = 0; length <= totalLength; length += step) {
const point = svgPath.getPointAtLength(length);
points[current++] = point;
}
return points;
};
export const pathStringToSVGPath = (pathString: string) => {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("d", pathString);
return svg;
};
import { CSSTransform } from "../types";
export const TRANSFORM_KEYS = [
"x",
"y",
"translate",
"scale",
"rotate",
"skew",
"translateX",
"translateY",
"translateZ",
"translate3d",
"scaleX",
"scaleY",
"scaleZ",
"scale3d",
"rotateX",
"rotateY",
"rotateZ",
"rotate3d",
"skewX",
"skewY",
"skewZ",
"skew3d",
] as const;
export const isTransform = (
value: unknown,
): value is NonNullable<(typeof TRANSFORM_KEYS)[number]> =>
TRANSFORM_KEYS.includes(value as never);
type Transform = (typeof TRANSFORM_KEYS)[number];
export const CssTransformVars = Object.fromEntries(
TRANSFORM_KEYS.map((type) => [type, `--svelte-anim-${type}`] as const),
) as Record<Transform, `--svelte-anim-${Transform & string}`>;
export const buildTransform = (key: Transform, value: string) => {
if (key === "x" || key === "y") {
return `translate${key.toLocaleUpperCase()}(${value}) `;
} else {
return `${key}(${value})`;
}
};
const compareTransforms = (a: string, b: string) => {
return (
TRANSFORM_KEYS.indexOf(a as never) - TRANSFORM_KEYS.indexOf(b as never)
);
};
export function combineTransforms(
transform1: string,
transform2: string,
): string {
const transforms1 = transform1.split(/\s+/);
const transforms2 = transform2.split(/\s+/);
const combinedTransforms = [...transforms2];
const handleTransform = (transform: string) => {
const [type, value] = transform.split(/\((.*)\)/) as [CSSTransform, string];
const matchingIndex = combinedTransforms.findIndex((t) =>
t.startsWith(type),
);
if (matchingIndex !== -1) {
const matchingTransform = combinedTransforms[matchingIndex];
const [existingType, existingValue = "0"] =
matchingTransform.split(/\((.*)\)/);
const combinedValue = parseFloat(existingValue) - parseFloat(value);
if (combinedValue) {
console.log(combinedValue);
combinedTransforms[matchingIndex] = `${existingType}(${combinedValue})`;
return;
} else if (type) {
combinedTransforms[matchingIndex] = `${type}(${value})`;
}
}
combinedTransforms.push(transform);
};
for (const transform of transforms1) {
handleTransform(transform);
}
return combinedTransforms.sort(compareTransforms).join(" ").trim();
}
interface RelativeTransform {
property: string;
relativeValue: string;
value: string;
}
function parseTransform(
transformString: string,
): Omit<RelativeTransform, "relativeValue"> {
const [property, value] = transformString.split(/\s*\(\s*/);
return { property, value: value.replace(/\s*\)\s*$/, "") };
}
export function calculateRelativeTranslation(
transforms: string[],
): RelativeTransform[] {
const parsedTransforms = transforms.map(parseTransform);
let translationX = 0;
let translationY = 0;
let scaleX = 1;
let scaleY = 1;
const relativeTransforms: RelativeTransform[] = [];
for (const transform of parsedTransforms) {
const { property, value } = transform;
if (property === "translateX") {
const x = parseFloat(value) || 0;
const relativeX = x - translationX;
translationX = x;
relativeTransforms.push({
property,
value,
relativeValue: `${relativeX}px`,
});
} else if (property === "translateY") {
const y = parseFloat(value) || 0;
const relativeY = y - translationY;
translationY = y;
relativeTransforms.push({
property,
value,
relativeValue: `${relativeY}px`,
});
} else if (property === "scaleX") {
const x = parseFloat(value) || 1;
scaleX *= x;
relativeTransforms.push({ property, value, relativeValue: `${scaleX}` });
} else if (property === "scaleY") {
const y = parseFloat(value) || 1;
scaleY *= y;
relativeTransforms.push({ property, value, relativeValue: `${scaleY}` });
} else {
relativeTransforms.push({ property, value, relativeValue: value });
}
}
return relativeTransforms;
}
interface ParsedTransform {
property: string;
value: string;
}
export function parseCSSTransform(transformString: string): ParsedTransform[] {
const transforms: ParsedTransform[] = [];
const transformRegex = /(\w+)\(([^)]+)\)/g;
let match;
while ((match = transformRegex.exec(transformString))) {
const [, property, value] = match;
transforms.push({ property, value });
}
return transforms;
}
const CSSUnitMap = {
"%": "%",
px: "px",
em: "em",
rem: "rem",
vw: "vw",
vh: "vh",
vmin: "vmin",
vmax: "vmax",
pt: "pt",
pc: "pc",
in: "in",
mm: "mm",
cm: "cm",
} as const;
type CSSUnit = keyof typeof CSSUnitMap
function convertUnits(fromValue: string | number, toUnit: CSSUnit): number {
if (typeof fromValue === 'number') fromValue = fromValue.toString() + toUnit;
const parseValue = (valueUnit: string): [number, CSSUnit] => {
const value = parseFloat(valueUnit);
if (isNaN(value)) {
throw new Error(`Invalid value: ${valueUnit}`);
}
const unit = valueUnit.replace(value.toString(), '') as CSSUnit;
return [value, unit];
};
const [from, fromUnit] = parseValue(fromValue);
const to = CSSUnitMap[toUnit];
if (fromUnit === "%" && to === "px") {
return (from / 100) * window.innerWidth;
}
if (fromUnit === "px" && to === "%") {
return (from / window.innerWidth) * 100;
}
const conversionTable: { [key in Exclude<CSSUnit, 'px' | '%'>]: number } = {
// Absolute length units
in: 96,
cm: 37.8,
mm: 3.78,
pt: 1.33,
pc: 16,
// Relative length units
em: parseFloat(getComputedStyle(document.documentElement).fontSize || "16px"),
rem: parseFloat(getComputedStyle(document.documentElement).fontSize || "16px"),
vw: window.innerWidth / 100,
vh: window.innerHeight / 100,
vmin: Math.min(window.innerWidth, window.innerHeight) / 100,
vmax: Math.max(window.innerWidth, window.innerHeight) / 100,
};
const fromPixels = from * conversionTable[fromUnit as never];
return fromPixels / conversionTable[to as never];
}
export { convertUnits, CSSUnitMap };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment