feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { solveBezierX } from '../../internal/spline';
|
||||
import { framesToSeconds, secondsToFrames } from '../../internal/scale';
|
||||
import { DEFAULT_KEYFRAME_EASING } from './context';
|
||||
import type { KeyframeTrackKeyframeData } from './context';
|
||||
|
||||
/**
|
||||
* Sort keyframes ascending by `time`, returning a NEW array (never mutating the
|
||||
* input). Stable for equal times (a tie breaks on `id`) so the order is
|
||||
* deterministic across reconciles and neighbour-clamping stays predictable.
|
||||
*/
|
||||
export function sortKeyframes(keyframes: readonly KeyframeTrackKeyframeData[]): KeyframeTrackKeyframeData[] {
|
||||
return keyframes
|
||||
.slice()
|
||||
.sort((a, b) => (a.time - b.time) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolate between `a` and `b` by `t ∈ [0, 1]`.
|
||||
*/
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the animated value at an arbitrary `time` (seconds) over a SORTED
|
||||
* keyframe list.
|
||||
*
|
||||
* Finds the bracketing pair `[k, k+1]`, computes the normalized progress along
|
||||
* the segment, applies the starting keyframe's cubic-bezier easing (via the
|
||||
* spline `solveBezierX`, defaulting to {@link DEFAULT_KEYFRAME_EASING} — a linear
|
||||
* ramp), and lerps the value. The result is CONSTANT outside the keyframe range
|
||||
* (held at the first / last keyframe's value) and for the 0- and 1-keyframe
|
||||
* degenerate cases.
|
||||
*
|
||||
* `valueRange` is accepted for parity with the projection model but does not
|
||||
* affect the sampled value (values are sampled in their own space, never
|
||||
* normalized) — it is reserved so callers can pass it without a second overload.
|
||||
*
|
||||
* @param keyframes Keyframes sorted ascending by `time`.
|
||||
* @param time Time to sample, in seconds.
|
||||
* @param valueRange Optional value domain (unused by the maths; see above).
|
||||
*/
|
||||
export function sampleKeyframes(
|
||||
keyframes: readonly KeyframeTrackKeyframeData[],
|
||||
time: number,
|
||||
_valueRange?: readonly [number, number],
|
||||
): number {
|
||||
const n = keyframes.length;
|
||||
if (n === 0) return 0;
|
||||
const first = keyframes[0]!;
|
||||
if (n === 1) return first.value;
|
||||
const last = keyframes[n - 1]!;
|
||||
|
||||
// Held constant outside the keyframe range.
|
||||
if (time <= first.time) return first.value;
|
||||
if (time >= last.time) return last.value;
|
||||
|
||||
// Binary search for the segment [lo, lo+1] containing `time`.
|
||||
let lo = 0;
|
||||
let hi = n - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (keyframes[mid]!.time <= time) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
|
||||
const a = keyframes[lo]!;
|
||||
const b = keyframes[lo + 1]!;
|
||||
const span = b.time - a.time;
|
||||
if (span <= 0) return a.value;
|
||||
|
||||
const progress = (time - a.time) / span;
|
||||
const easing = a.easing ?? DEFAULT_KEYFRAME_EASING;
|
||||
// Easing maps normalized progress (x) to eased progress (y) in [0, 1].
|
||||
const eased = solveBezierX(easing[0], easing[1], easing[2], easing[3], progress);
|
||||
return lerp(a.value, b.value, eased);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a candidate `time` for the keyframe at `index` so it stays ordered
|
||||
* relative to its neighbours by at least `minTimeBetween` seconds (unless
|
||||
* `allowOverlap`), and never goes below `0`. `keyframes` MUST be sorted by time.
|
||||
*
|
||||
* @param keyframes Keyframes sorted ascending by `time`.
|
||||
* @param index Index of the keyframe being moved.
|
||||
* @param time Candidate time (seconds).
|
||||
* @param options Neighbour-clamp configuration.
|
||||
*/
|
||||
export function clampKeyframeTime(
|
||||
keyframes: readonly KeyframeTrackKeyframeData[],
|
||||
index: number,
|
||||
time: number,
|
||||
options: { allowOverlap: boolean; minTimeBetween: number; duration?: number },
|
||||
): number {
|
||||
const { allowOverlap, minTimeBetween, duration } = options;
|
||||
let v = Math.max(0, time);
|
||||
if (duration !== undefined && duration > 0) v = Math.min(v, duration);
|
||||
|
||||
if (!allowOverlap) {
|
||||
const prev = keyframes[index - 1];
|
||||
const next = keyframes[index + 1];
|
||||
if (prev !== undefined) v = Math.max(v, prev.time + minTimeBetween);
|
||||
if (next !== undefined) v = Math.min(v, next.time - minTimeBetween);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a `time` (seconds) to the nearest whole frame at `fps`. The default
|
||||
* frame-grid quantizer used as the keyboard nudge granularity / snap fallback.
|
||||
*/
|
||||
export function snapTimeToFrame(time: number, fps: number): number {
|
||||
if (fps <= 0) return time;
|
||||
return framesToSeconds(secondsToFrames(time, fps), fps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round to `decimals` places, trimming float noise (no trailing-zero padding).
|
||||
*/
|
||||
function round(value: number, decimals: number): number {
|
||||
const f = 10 ** decimals;
|
||||
return Math.round(value * f) / f;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default `aria-valuetext` value-token for a keyframe: the animated property
|
||||
* (when present) followed by the value, e.g. `"opacity 0.5"` or just `"0.5"`.
|
||||
* The time is announced separately by the caller (a slider's `aria-valuetext`
|
||||
* leads with the formatted time).
|
||||
*/
|
||||
export function defaultKeyframeValueText(value: number, property?: string, decimals = 3): string {
|
||||
const v = round(value, decimals);
|
||||
return property ? `${property} ${v}` : `${v}`;
|
||||
}
|
||||
Reference in New Issue
Block a user