@humanspeak/svelte-motion
Version:
Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values
190 lines (189 loc) • 6.97 kB
JavaScript
// Utilities for building and validating transform strings
import { isMotionValue, mapValue, transformValue } from 'motion-dom';
import {} from 'svelte/store';
import { augmentMotionValue, bridgeReadableToMotionValue } from './augmentMotionValue.svelte.js';
const DEFAULTS = {
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0
};
/**
* Build a CSS transform string from numeric values (no matrices).
*
* @param values Partial map of translate/scale/rotate values.
* @returns A space-separated CSS `transform` string, or `""` when all values are defaults.
*
* @example
* ```ts
* buildTransform({ x: 10, y: 20, scale: 1.5 })
* // => "translate(10px, 20px) scale(1.5)"
*
* buildTransform({ x: 0, y: 0, rotate: 0 }) // all defaults
* // => ""
* ```
*/
export const buildTransform = (values) => {
const v = { ...DEFAULTS, ...values };
const useAxes = values.scaleX !== undefined || values.scaleY !== undefined;
const parts = [];
if (v.x !== 0 || v.y !== 0)
parts.push(`translate(${round(v.x)}px, ${round(v.y)}px)`);
if (v.rotate !== 0)
parts.push(`rotate(${round(v.rotate)}deg)`);
if (useAxes) {
parts.push(`scaleX(${round(v.scaleX)})`);
parts.push(`scaleY(${round(v.scaleY)})`);
}
else if (v.scale !== 1) {
parts.push(`scale(${round(v.scale)})`);
}
return parts.join(' ').trim();
};
/**
* Lightweight safety check for transform magnitudes and NaN values.
*
* @param values Transform values to validate.
* @param opts Optional configuration; `maxScale` caps allowable absolute scale (default 8).
* @returns `true` if all scale values are finite and within bounds.
*
* @example
* ```ts
* isSafeTransform({ scale: 2 }) // true
* isSafeTransform({ scale: 100 }) // false (exceeds default maxScale=8)
* isSafeTransform({ scale: 100 }, { maxScale: 200 }) // true
* isSafeTransform({ scale: NaN }) // false
* ```
*/
export const isSafeTransform = (values, opts) => {
const maxScale = opts?.maxScale ?? 8;
const entries = [
['scale', values.scale],
['scaleX', values.scaleX],
['scaleY', values.scaleY]
];
for (const [, val] of entries) {
if (val === undefined)
continue;
if (!Number.isFinite(val))
return false;
if (Math.abs(val) > maxScale)
return false;
}
return true;
};
/**
* Extract the uniform scale factor from a CSS `matrix()` string.
*
* @param matrix A CSS `matrix(...)` value, `"none"`, `null`, or `undefined`.
* @returns The `a` component of the matrix (uniform scale), or `null` if unparseable.
*
* @example
* ```ts
* parseMatrixScale('matrix(1.5, 0, 0, 1.5, 0, 0)') // 1.5
* parseMatrixScale('none') // null
* parseMatrixScale(null) // null
* ```
*/
export const parseMatrixScale = (matrix) => {
if (!matrix || matrix === 'none')
return null;
const m = matrix.match(/matrix\(([^)]+)\)/);
if (!m)
return null;
const [a] = m[1].split(',').map((s) => parseFloat(s.trim()));
return Number.isFinite(a) ? a : null;
};
/**
* Round a number to six decimal places to avoid excessive precision in CSS strings.
*
* @param n The number to round.
* @returns The rounded value.
*/
const round = (n) => {
return Math.round(n * 1e6) / 1e6;
};
/**
* Normalizes a `TransformSource<T>` into a `MotionValue<T>`. Returns the
* source as-is for any motion-value input (no bridge), or a bridge
* `MotionValue` + cleanup for `Readable` inputs. The cast at the
* motion-value branch is safe — both `MotionValue` and `AugmentedMotionValue`
* are the same instance at runtime.
*/
const toMotionValue = (source) => {
if (isMotionValue(source)) {
return { value: source, dispose: () => undefined };
}
return bridgeReadableToMotionValue(source);
};
export function useTransform(sourceOrCompute, inputOrTransformer, outputOrOutputMap, options) {
// Compute form: useTransform(() => compute).
if (typeof sourceOrCompute === 'function') {
const compute = sourceOrCompute;
const value = transformValue(compute);
$effect(() => () => value.destroy());
return augmentMotionValue(value);
}
// Multi-input list form: useTransform([mv, mv, …], ([a, b, …]) => O).
if (Array.isArray(sourceOrCompute) && typeof inputOrTransformer === 'function') {
const sources = sourceOrCompute;
const transformer = inputOrTransformer;
const value = transformValue(() => {
const latest = [];
for (let i = 0; i < sources.length; i++) {
latest.push(sources[i].get());
}
return transformer(latest);
});
$effect(() => () => value.destroy());
return augmentMotionValue(value);
}
// Single-MV transformer form: useTransform(mv, (latest) => O).
if (typeof inputOrTransformer === 'function') {
const source = sourceOrCompute;
const transformer = inputOrTransformer;
const value = transformValue(() => transformer(source.get()));
$effect(() => () => value.destroy());
return augmentMotionValue(value);
}
// Mapping forms (single output array or output map).
const source = sourceOrCompute;
const input = inputOrTransformer ?? [];
const { value: numericSource, dispose: disposeBridge } = toMotionValue(source);
// Multi-output mapping form: useTransform(source, [range], { key: [out], … }, options).
// The `outputOrOutputMap !== null` check is load-bearing — `typeof null`
// is `'object'`, so a `null` argument would otherwise enter this branch
// and crash at `Object.keys(null)`.
if (outputOrOutputMap !== undefined &&
outputOrOutputMap !== null &&
!Array.isArray(outputOrOutputMap) &&
typeof outputOrOutputMap === 'object') {
const outputMap = outputOrOutputMap;
const keys = Object.keys(outputMap);
const result = {};
const inners = [];
for (const key of keys) {
const inner = mapValue(numericSource, input, outputMap[key], options);
inners.push(inner);
result[key] = augmentMotionValue(inner);
}
// One cleanup effect for all per-key MVs plus the shared bridge —
// saves N effect nodes vs. registering one per key.
$effect(() => () => {
for (const inner of inners)
inner.destroy();
disposeBridge();
});
return result;
}
// Single-output mapping form: useTransform(source, [range], [out], options).
const output = outputOrOutputMap ?? [];
const value = mapValue(numericSource, input, output, options);
$effect(() => () => {
value.destroy();
disposeBridge();
});
return augmentMotionValue(value);
}