Last active
July 21, 2023 21:04
-
-
Save snuffyDev/4bffa735faa41c477708272f24981194 to your computer and use it in GitHub Desktop.
Animation Library made with TypeScript
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 { 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[]; | |
} |
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 { 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); | |
} |
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 { 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(); | |
} | |
} | |
} | |
} |
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
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; | |
}; |
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 { 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; | |
} |
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
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