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:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,97 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Embeds a `CurveEditorRoot` (in `'bezier'` interpolation) bound to the SELECTED
* keyframe's segment easing. The curve's two anchors are pinned at `(0, 0)` and
* `(1, 1)` (CSS `cubic-bezier` semantics); the start anchor's `outHandle` and the
* end anchor's `inHandle` map to the easing tuple `[x1, y1, x2, y2]`.
*
* The binding is one-way IN (the editor is seeded from the keyframe's easing via
* the CurveEditor `defaultValue`) and one-way OUT (every anchor commit reads the
* handles back and calls `ctx.setEasing`). The CurveEditor is remounted (keyed on
* the selected id) whenever the selection changes so its seed always reflects the
* newly selected keyframe.
*
* Renders nothing unless a keyframe with a FOLLOWING segment is selected (the
* last keyframe has no outgoing segment to ease).
*/
export interface KeyframeTrackEasingEditorProps extends PrimitiveProps {
/** Sample count for the rendered easing polyline. @default 256 */
samples?: number;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { CurveEditorRoot } from '../curve-editor';
import type { CurveEditorAnchor } from '../curve-editor';
import { DEFAULT_KEYFRAME_EASING, useKeyframeTrackContext } from './context';
const { samples = 256, as = 'div' } = defineProps<KeyframeTrackEasingEditorProps>();
const ctx = useKeyframeTrackContext();
const { forwardRef } = useForwardExpose();
// The selected keyframe must exist AND have a following segment to ease.
const selected = computed(() => {
const id = ctx.selectedId.value;
if (id === null) return null;
const list = ctx.keyframes.value;
const i = list.findIndex(k => k.id === id);
if (i === -1 || i >= list.length - 1) return null;
return list[i]!;
});
// Seed anchors for the embedded CurveEditor from the segment's easing tuple.
// Anchors are pinned at (0,0)/(1,1); the handles are the bezier control points
// relative to their anchor (CSS cubic-bezier control points).
const seedAnchors = computed<CurveEditorAnchor[]>(() => {
const kf = selected.value;
const e = kf?.easing ?? DEFAULT_KEYFRAME_EASING;
const [x1, y1, x2, y2] = e;
return [
{ id: 'kf-easing-start', x: 0, y: 0, outHandle: { x: x1, y: y1 } },
{ id: 'kf-easing-end', x: 1, y: 1, inHandle: { x: x2 - 1, y: y2 - 1 } },
];
});
// Read the handles back off the committed anchors and write the easing tuple.
function onAnchorsCommit(anchors: CurveEditorAnchor[]): void {
const kf = selected.value;
if (!kf || anchors.length < 2) return;
const startA = anchors[0]!;
const endA = anchors[anchors.length - 1]!;
const x1 = startA.outHandle ? startA.x + startA.outHandle.x : 0;
const y1 = startA.outHandle ? startA.y + startA.outHandle.y : 0;
const x2 = endA.inHandle ? endA.x + endA.inHandle.x : 1;
const y2 = endA.inHandle ? endA.y + endA.inHandle.y : 1;
ctx.setEasing(kf.id, [x1, y1, x2, y2]);
}
</script>
<template>
<Primitive
v-if="selected"
:ref="forwardRef"
:as="as"
data-easing-editor
:data-disabled="ctx.disabled.value ? '' : undefined"
>
<CurveEditorRoot
:key="selected.id"
interpolation="bezier"
:default-value="seedAnchors"
:samples="samples"
:disabled="ctx.disabled.value"
:dir="ctx.direction.value"
@anchors-commit="onAnchorsCommit"
>
<template #default="curveProps">
<slot v-bind="curveProps" :keyframe="selected" />
</template>
</CurveEditorRoot>
</Primitive>
</template>
@@ -0,0 +1,211 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A single draggable keyframe on the track, rendered as `role="slider"`. It
* positions itself by its `time` (horizontal projection) and — in `valueAxis`
* mode — its `value` (vertical projection), and handles pointer drags plus
* keyboard editing.
*
* The single `aria-valuenow` carries the keyframe TIME in seconds (or its value
* in `valueAxis` mode); `aria-valuetext` announces the formatted time, the
* property name, and the value. Keyframes share one tab-stop (roving focus): Tab
* moves between them, the selected keyframe is the active stop. Left/Right nudge
* the time by `step` (Shift = `largeStep`, dir-aware, neighbour-clamped);
* Up/Down nudge the `value` by `valueStep` in `valueAxis` mode (else roving
* focus); Home/End jump the time to min/max; Delete removes the keyframe.
*/
export interface KeyframeTrackKeyframeProps extends PrimitiveProps {
/** The id of the keyframe this slider renders. */
keyframeId: string;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { usePointerDrag } from '../../internal/pointer-drag';
import { useKeyframeTrackContext } from './context';
import { defaultKeyframeValueText } from './utils';
const { keyframeId, as = 'div' } = defineProps<KeyframeTrackKeyframeProps>();
const ctx = useKeyframeTrackContext();
// O(1) lookup via the root's memoized id → index map instead of scanning the
// keyframe array twice (find + findIndex) per part on every drag frame.
const index = computed(() => ctx.indexById.value.get(keyframeId) ?? -1);
const keyframe = computed(() => ctx.keyframes.value[index.value]);
const isSelected = computed(() => ctx.selectedId.value === keyframeId);
const isDragging = computed(() => ctx.draggingId.value === keyframeId);
// Roving focus: only the selected keyframe (or the first, when none selected) is
// in the tab order.
const isTabStop = computed(() => {
if (ctx.disabled.value) return false;
if (ctx.selectedId.value === null) return index.value === 0;
return isSelected.value;
});
const tabindex = computed(() => {
if (ctx.disabled.value) return -1;
return isTabStop.value ? 0 : -1;
});
// ── position ───────────────────────────────────────────────────────────────
const pxX = computed(() => (keyframe.value ? ctx.projection(keyframe.value.time) : 0));
const pxY = computed(() => (keyframe.value ? ctx.projectValue(keyframe.value.value) : 0));
const positionStyle = computed<{ left: string; top: string | undefined }>(() => ({
left: `${pxX.value}px`,
top: ctx.valueAxis.value ? `${pxY.value}px` : undefined,
}));
// ── ARIA ─────────────────────────────────────────────────────────────────────
const ariaValueMin = computed(() => {
if (ctx.valueAxis.value) return Math.min(ctx.valueRange.value[0], ctx.valueRange.value[1]);
return 0;
});
const ariaValueMax = computed(() => {
if (ctx.valueAxis.value) return Math.max(ctx.valueRange.value[0], ctx.valueRange.value[1]);
return ctx.duration.value;
});
const ariaValueNow = computed(() => {
if (!keyframe.value) return 0;
return ctx.valueAxis.value ? keyframe.value.value : keyframe.value.time;
});
const ariaValueText = computed(() => {
if (!keyframe.value) return undefined;
const time = ctx.formatTime(keyframe.value.time);
const valueText = defaultKeyframeValueText(keyframe.value.value, ctx.property.value);
return `${time}, ${valueText}`;
});
// ── roving registration ───────────────────────────────────────────────────────
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (node) => {
ctx.registerKeyframeEl(keyframeId, node ?? null);
});
onBeforeUnmount(() => ctx.registerKeyframeEl(keyframeId, null));
// ── pointer drag ──────────────────────────────────────────────────────────────
// x → time, y → value (valueAxis). Capture the keyframe's pixel origin at drag
// start so cumulative client-px totals (which equal lane px 1:1) project back
// through the projections without needing the lane rect.
let dragOriginX = 0;
let dragOriginY = 0;
usePointerDrag(currentElement, {
axis: 'both',
threshold: 0,
disabled: () => ctx.disabled.value,
onStart: () => {
ctx.select(keyframeId);
dragOriginX = pxX.value;
dragOriginY = pxY.value;
},
onMove: (state) => {
if (!keyframe.value) return;
const rawTime = ctx.invert(dragOriginX + state.total.x);
const time = ctx.snapTime(rawTime, keyframeId);
const value = ctx.valueAxis.value ? ctx.invertValue(dragOriginY + state.total.y) : undefined;
ctx.moveKeyframe(keyframeId, time, value, true);
},
onEnd: () => {
ctx.commit();
},
});
// ── click / keyboard ──────────────────────────────────────────────────────────
function onPointerDownSelect(): void {
if (ctx.disabled.value) return;
ctx.select(keyframeId);
}
function nudgeTime(deltaSeconds: number): void {
if (!keyframe.value) return;
ctx.moveKeyframe(keyframeId, keyframe.value.time + deltaSeconds, undefined, false);
}
function nudgeValue(deltaValue: number): void {
if (!keyframe.value) return;
ctx.moveKeyframe(keyframeId, keyframe.value.time, keyframe.value.value + deltaValue, false);
}
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value || !keyframe.value) return;
const rtl = ctx.direction.value === 'rtl';
const unit = event.shiftKey ? ctx.largeStep.value : ctx.step.value;
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
nudgeTime(rtl ? -unit : unit);
return;
case 'ArrowLeft':
event.preventDefault();
nudgeTime(rtl ? unit : -unit);
return;
case 'ArrowUp':
event.preventDefault();
if (ctx.valueAxis.value) nudgeValue(ctx.valueStep.value);
else ctx.focusAdjacent(keyframeId, 1);
return;
case 'ArrowDown':
event.preventDefault();
if (ctx.valueAxis.value) nudgeValue(-ctx.valueStep.value);
else ctx.focusAdjacent(keyframeId, -1);
return;
case 'Home':
event.preventDefault();
nudgeTime(-Number.MAX_SAFE_INTEGER);
return;
case 'End':
event.preventDefault();
nudgeTime(Number.MAX_SAFE_INTEGER);
return;
case 'Delete':
case 'Backspace':
event.preventDefault();
ctx.removeKeyframe(keyframeId);
break;
default:
break;
}
}
function onFocus(): void {
if (!ctx.disabled.value) ctx.select(keyframeId);
}
</script>
<template>
<Primitive
v-if="keyframe"
:ref="forwardRef"
:as="as"
role="slider"
:tabindex="tabindex"
:aria-valuemin="ariaValueMin"
:aria-valuemax="ariaValueMax"
:aria-valuenow="ariaValueNow"
:aria-valuetext="ariaValueText"
:aria-orientation="ctx.valueAxis.value ? 'vertical' : 'horizontal'"
:aria-selected="isSelected || undefined"
:aria-disabled="ctx.disabled.value || undefined"
:data-selected="isSelected ? '' : undefined"
:data-dragging="isDragging ? '' : undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:style="positionStyle"
@keydown="onKeyDown"
@pointerdown="onPointerDownSelect"
@focus="onFocus"
>
<slot
:keyframe="keyframe"
:selected="isSelected"
:dragging="isDragging"
:x="pxX"
:y="pxY"
/>
</Primitive>
</template>
@@ -0,0 +1,520 @@
<script lang="ts">
import type { Ref } from 'vue';
import type { PrimitiveProps } from '../../internal/primitive';
import type { Direction } from '../../utilities/config-provider';
import type { KeyframeTrackContext, KeyframeTrackKeyframeData } from './context';
/**
* Root of the headless keyframe track: animation keyframes laid out on a time
* axis, each segment carrying an editable cubic-bezier easing. It owns the
* keyframe array (two-way via `v-model` or uncontrolled via `defaultValue`),
* builds the time↔pixel projection (its own `useScale` when standalone, or the
* injected Timeline's scale when nested as a lane), and exposes the live
* sampler `sampleAt(time)` plus the easing editor binding.
*
* Transient drag positions are written to an in-flight overlay and committed on
* pointerup (`commit`); an external `v-model` write during a gesture is ignored
* (the `isMutating` early-return) so it never clobbers the live drag — mirroring
* the Timeline reconcile.
*
* Provides `KeyframeTrackContext` to every part: the projection, the shared
* frame-grid snap engine, the keyframe actions, and the roving-focus registry.
* When nested in a Timeline it derives `duration` / `fps` from that context and
* renders as a `listitem`; standalone it measures its own lane and renders as a
* `group`.
*/
export interface KeyframeTrackRootProps extends PrimitiveProps {
/** Controlled keyframes (`v-model`). */
modelValue?: KeyframeTrackKeyframeData[];
/** Uncontrolled initial keyframes (ignored when `v-model` is bound). @default [] */
defaultValue?: KeyframeTrackKeyframeData[];
/** The animated property name (drives the a11y label / value text). */
property?: string;
/** Keyframes move vertically to edit `value` (else a single horizontal lane). @default false */
valueAxis?: boolean;
/** Value domain `[min, max]` (the y-axis extent in `valueAxis` mode). @default [0, 1] */
valueRange?: [number, number];
/**
* Total track duration in seconds. When omitted it is auto-derived from the
* keyframes (largest `time`) standalone, or inherited from a Timeline.
* @default auto
*/
duration?: number;
/** Frame rate (timecode + frame snapping + keyboard nudge). @default 30 */
fps?: number;
/** Keyboard nudge step in seconds. @default 1/fps */
step?: number;
/** Large keyboard step in seconds (Shift+Arrow). @default 10/fps */
largeStep?: number;
/** Value-axis keyboard nudge step (per Arrow Up/Down in `valueAxis` mode). @default 0.01 */
valueStep?: number;
/** Snap step in seconds (frame grid). @default 1/fps */
snapStep?: number;
/** Enable magnetic snapping to the frame grid. @default true */
snapping?: boolean;
/** Allow keyframes to overlap in time (else neighbour-clamped to keep order). @default false */
allowOverlap?: boolean;
/** Minimum time gap between neighbours (seconds) when `allowOverlap` is false. @default 1/fps */
minTimeBetween?: number;
/** Snap radius in pixels. @default 8 */
snapThresholdPx?: number;
/** Selected keyframe id (`v-model:selectedId`). */
selectedId?: string | null;
/** Disable all interaction. @default false */
disabled?: boolean;
/**
* Writing direction. When omitted it is inherited from the nearest
* `ConfigProvider` (falling back to `'ltr'`); an explicit value wins.
*/
dir?: Direction;
}
// `update:*` events are declared by their `defineModel`s and must NOT be
// re-declared here.
export interface KeyframeTrackRootEmits {
/** Emitted when a keyframe drag / keypress settles, with the affected id. */
keyframeCommit: [id: string];
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, triggerRef, watch } from 'vue';
import { clamp } from '@robonen/stdlib';
import { useElementSize, useForwardExpose, useId } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useDirection } from '../../utilities/config-provider';
import { formatClock, framesToTimecode, secondsToFrames, useScale } from '../../internal/scale';
import { gridTargets, useSnapping } from '../../internal/snapping';
import type { SnapTarget } from '../../internal/snapping';
import { useTimelineContext } from '../timeline/context';
import type { TimelineContext } from '../timeline/context';
import { provideKeyframeTrackContext } from './context';
import { clampKeyframeTime, sampleKeyframes, sortKeyframes } from './utils';
const {
modelValue,
defaultValue,
property,
valueAxis = false,
valueRange = [0, 1] as [number, number],
duration: durationProp,
fps: fpsProp = 30,
step: stepProp,
largeStep: largeStepProp,
valueStep = 0.01,
snapStep: snapStepProp,
snapping = true,
allowOverlap = false,
minTimeBetween: minTimeBetweenProp,
snapThresholdPx = 8,
selectedId: selectedIdProp,
disabled = false,
dir,
as = 'div',
} = defineProps<KeyframeTrackRootProps>();
const emit = defineEmits<KeyframeTrackRootEmits>();
const trackId = useId(undefined, 'keyframe-track').value;
const localDirection = useDirection(() => dir);
// Optionally nest inside a Timeline: when present, derive duration / fps from it.
// The factory's inject returns the `null` fallback when no Timeline is provided
// (never undefined), so it does not throw; cast keeps the optional null type.
const timeline = useTimelineContext(null as unknown as TimelineContext) as TimelineContext | null;
const inTimeline = timeline !== null;
// ── models (controlled + uncontrolled) ──────────────────────────────────────
const localKeyframes = shallowRef<KeyframeTrackKeyframeData[]>(
sortKeyframes(modelValue ?? defaultValue ?? []),
);
const model = defineModel<KeyframeTrackKeyframeData[]>('modelValue', {
get: external => external ?? localKeyframes.value,
set: (value) => {
localKeyframes.value = sortKeyframes(value);
return value;
},
});
const selectedModel = defineModel<string | null>('selectedId', {
default: undefined as unknown as string | null,
});
if (selectedModel.value === undefined) selectedModel.value = selectedIdProp ?? null;
// ── transient overlay (in-flight drag) ───────────────────────────────────────
const isMutating = shallowRef(false);
const draggingId = shallowRef<string | null>(null);
// The live working copy: an immutable, sorted array. During a drag this holds
// the transient positions; on commit it is written back to the model.
const working = shallowRef<KeyframeTrackKeyframeData[]>(sortKeyframes(model.value ?? []));
function reconcile(): void {
// During an active gesture the model array is stale on purpose; don't clobber
// the live overlay until the gesture commits.
if (isMutating.value) return;
working.value = sortKeyframes(model.value ?? []);
}
// `deep: 1` (not full `deep: true`): the component's own writes always replace
// the array AND every element by reference (`candidate.map(k => ({ ...k }))`),
// and external `v-model` writes replace the whole array, so depth-1 (array ref +
// per-index element ref) catches every supported change without the O(n) deep
// walk into each keyframe's nested `value`/`easing` fields on every reconcile.
watch(model, reconcile, { immediate: true, deep: 1 });
const keyframes = computed(() => working.value);
// Memoized id → index over the live keyframes, rebuilt once per change. Parts
// read this for O(1) lookup instead of scanning the array (find/findIndex) per
// part per frame — keeps the whole-track per-frame cost O(n) rather than O(n²).
const indexById = computed(() => {
const m = new Map<string, number>();
const list = working.value;
for (let i = 0; i < list.length; i++) m.set(list[i]!.id, i);
return m;
});
// ── reactive prop refs ───────────────────────────────────────────────────────
const fps = toRef(() => fpsProp);
const frameStep = computed(() => (fpsProp > 0 ? 1 / fpsProp : 1));
const step = computed(() => (stepProp !== undefined && stepProp > 0 ? stepProp : frameStep.value));
const largeStep = computed(() => (largeStepProp !== undefined && largeStepProp > 0 ? largeStepProp : frameStep.value * 10));
const snapStep = computed(() => (snapStepProp !== undefined && snapStepProp > 0 ? snapStepProp : frameStep.value));
const minTimeBetween = computed(() => (minTimeBetweenProp !== undefined ? minTimeBetweenProp : frameStep.value));
// When nested, the Timeline owns direction / fps; standalone uses our own.
const direction = computed(() => (inTimeline ? timeline!.direction.value : localDirection.value));
const effectiveFps = computed(() => (inTimeline ? timeline!.fps.value : fpsProp));
// ── duration ─────────────────────────────────────────────────────────────────
const duration = computed(() => {
if (durationProp !== undefined && durationProp > 0) return durationProp;
if (inTimeline) return timeline!.duration.value;
let max = 0;
for (const k of working.value) if (k.time > max) max = k.time;
// A non-zero floor so a single keyframe still has a projectable range.
return max > 0 ? max : 1;
});
// ── element measurement (standalone) ─────────────────────────────────────────
const rootEl = shallowRef<HTMLElement | null>(null);
const { width: measuredWidth, height: measuredHeight } = useElementSize(rootEl);
const laneWidth = computed(() => {
if (inTimeline) return timeline!.viewportWidth.value;
return measuredWidth.value;
});
const laneHeight = computed(() => measuredHeight.value);
// ── coordinate model ─────────────────────────────────────────────────────────
const isRtl = computed(() => direction.value === 'rtl');
// Standalone time scale: domain [0, duration] → range [0, laneWidth].
const localScale = useScale({
domain: () => [0, duration.value] as const,
range: () => [0, measuredWidth.value] as const,
rtl: () => isRtl.value,
tickKind: 'none',
});
function projection(seconds: number): number {
if (inTimeline) return timeline!.scale(seconds);
if (measuredWidth.value <= 0) return 0;
return localScale.scale(seconds);
}
function invert(px: number): number {
if (inTimeline) return timeline!.invert(px);
if (measuredWidth.value <= 0) return 0;
return localScale.invert(px);
}
// Value y-axis (value-up): valueRange[0] → bottom (laneHeight), valueRange[1] → top (0).
const valueScale = useScale({
domain: () => valueRange,
range: () => [0, laneHeight.value] as const,
orientation: 'vertical',
clamp: true,
});
function projectValue(value: number): number {
if (laneHeight.value <= 0) return 0;
return valueScale.scale(value);
}
function invertValue(px: number): number {
if (laneHeight.value <= 0) return valueRange[0];
return valueScale.invert(px);
}
// ── formatting ─────────────────────────────────────────────────────────────
function formatTime(seconds: number): string {
// Inside a Timeline match its timecode; standalone use a wall-clock string,
// unless a sub-second frame resolution is meaningful.
if (inTimeline) return timeline!.formatTimecode(seconds);
if (effectiveFps.value > 0) return framesToTimecode(secondsToFrames(seconds, effectiveFps.value), effectiveFps.value);
return formatClock(seconds);
}
// ── snap engine (frame grid) ─────────────────────────────────────────────────
const snapTargets = computed<SnapTarget[]>(() => {
if (!snapping || laneWidth.value <= 0) return [];
const lo = inTimeline ? timeline!.invert(0) : 0;
const hi = inTimeline ? timeline!.invert(laneWidth.value) : duration.value;
return gridTargets(Math.min(lo, hi), Math.max(lo, hi), snapStep.value, projection, 'x');
});
const snapEngine = useSnapping({
enabled: () => snapping && !disabled && laneWidth.value > 0,
thresholdPx: () => snapThresholdPx,
// '1d' uses every target as a single pool with no per-call filter. All
// `snapTargets` are produced by `gridTargets(..., 'x')`, so they are already
// x-axis only — '1d' is behaviour-equivalent to 'x' here but skips the
// `allTargets.filter(t => t.axis === 'x')` allocation on every snap1d() call.
axis: '1d',
project: (value: number) => projection(value),
targets: () => snapTargets.value,
});
function snapTime(seconds: number, exclude?: string): number {
if (!snapping || disabled || laneWidth.value <= 0) return seconds;
return snapEngine.snap1d(seconds, exclude !== undefined ? { exclude } : undefined).value;
}
// ── sampling ─────────────────────────────────────────────────────────────────
function sampleAt(time: number): number {
return sampleKeyframes(working.value, time, valueRange);
}
/** Sample the value curve into `samples` evenly-spaced points across the duration. */
function getValueCurve(samples = 64): Array<{ time: number; value: number }> {
const out: Array<{ time: number; value: number }> = [];
const total = duration.value;
if (samples < 2 || total <= 0) return out;
for (let i = 0; i < samples; i++) {
const time = (i / (samples - 1)) * total;
out.push({ time, value: sampleAt(time) });
}
return out;
}
// ── selection ─────────────────────────────────────────────────────────────────
function select(id: string | null): void {
if (disabled) return;
if (selectedModel.value === id) return;
selectedModel.value = id;
}
// External selection writes are honoured (not blocked by mutation).
watch(() => selectedIdProp, (id) => {
if (id !== undefined && id !== selectedModel.value) selectedModel.value = id;
});
// ── mutation ────────────────────────────────────────────────────────────────
const dirtyIds = new Set<string>();
/** Write the working overlay (sorted, immutable) and flag the touched id dirty. */
function setKeyframes(next: KeyframeTrackKeyframeData[], dirtyId?: string): void {
working.value = sortKeyframes(next);
if (dirtyId !== undefined) dirtyIds.add(dirtyId);
triggerRef(working);
}
function indexOf(id: string): number {
const list = working.value;
for (let i = 0; i < list.length; i++) if (list[i]!.id === id) return i;
return -1;
}
function addKeyframe(time: number, value?: number): string | undefined {
if (disabled) return undefined;
const t = clamp(time, 0, duration.value || Number.MAX_SAFE_INTEGER);
const v = value ?? sampleAt(t);
const id = `${trackId}-kf-${idCounter++}`;
const next: KeyframeTrackKeyframeData = { id, time: t, value: v };
const candidate = sortKeyframes([...working.value, next]);
working.value = candidate;
// A keyframe add is a single committed mutation.
model.value = candidate.map(k => ({ ...k }));
emit('keyframeCommit', id);
return id;
}
function removeKeyframe(id: string): void {
if (disabled) return;
const candidate = working.value.filter(k => k.id !== id);
if (candidate.length === working.value.length) return;
working.value = candidate;
model.value = candidate.map(k => ({ ...k }));
if (selectedModel.value === id) selectedModel.value = null;
emit('keyframeCommit', id);
}
function moveKeyframe(id: string, time: number, value?: number, mutating = false): void {
if (disabled) return;
const index = indexOf(id);
if (index === -1) return;
const current = working.value[index]!;
isMutating.value = mutating;
draggingId.value = mutating ? id : draggingId.value;
const t = clampKeyframeTime(working.value, index, time, {
allowOverlap,
minTimeBetween: minTimeBetween.value,
duration: duration.value,
});
let v = current.value;
if (value !== undefined) v = clamp(value, Math.min(valueRange[0], valueRange[1]), Math.max(valueRange[0], valueRange[1]));
// Unchanged frame: the drag state was already set above; nothing to write.
if (t === current.time && v === current.value) return;
const candidate = working.value.slice();
candidate[index] = { ...current, time: t, value: v };
setKeyframes(candidate, id);
// A non-mutating move is an immediate commit (keyboard nudge).
if (!mutating) commit();
}
function setEasing(id: string, bezier: [number, number, number, number]): void {
if (disabled) return;
const index = indexOf(id);
if (index === -1) return;
const current = working.value[index]!;
const candidate = working.value.slice();
candidate[index] = { ...current, easing: bezier };
working.value = candidate;
triggerRef(working);
model.value = candidate.map(k => ({ ...k }));
emit('keyframeCommit', id);
}
function commit(): void {
const wasMutating = isMutating.value;
isMutating.value = false;
draggingId.value = null;
if (dirtyIds.size === 0 && !wasMutating) return;
const ids = [...dirtyIds];
dirtyIds.clear();
if (ids.length === 0) return;
// Write the model immutably from the working overlay.
model.value = working.value.map(k => ({ ...k }));
for (const id of ids) emit('keyframeCommit', id);
}
let idCounter = working.value.length;
// ── roving focus ─────────────────────────────────────────────────────────────
const keyframeEls = new Map<string, HTMLElement>();
function registerKeyframeEl(id: string, el: HTMLElement | null): void {
if (el) keyframeEls.set(id, el);
else keyframeEls.delete(id);
}
function focusKeyframe(id: string): void {
keyframeEls.get(id)?.focus();
}
function focusAdjacent(fromId: string, dirSign: 1 | -1): void {
const order = working.value;
const idx = order.findIndex(k => k.id === fromId);
if (idx === -1) return;
const nextIdx = idx + dirSign;
if (nextIdx < 0 || nextIdx >= order.length) return;
focusKeyframe(order[nextIdx]!.id);
}
// ── a11y ──────────────────────────────────────────────────────────────────────
const ariaLabel = computed(() => (property ? `${property} keyframes` : undefined));
// ── provide ────────────────────────────────────────────────────────────────────
const context: KeyframeTrackContext = {
trackId,
keyframes,
indexById,
selectedId: selectedModel as Ref<string | null>,
property: toRef(() => property),
valueAxis: toRef(() => valueAxis),
valueRange: toRef(() => valueRange),
duration,
fps,
step,
largeStep,
valueStep: toRef(() => valueStep),
allowOverlap: toRef(() => allowOverlap),
minTimeBetween,
snapping: toRef(() => snapping),
disabled: toRef(() => disabled),
direction,
laneWidth,
laneHeight,
projection,
invert,
projectValue,
invertValue,
formatTime,
snapTime,
snapEngine,
isMutating,
draggingId,
inTimeline,
sampleAt,
select,
addKeyframe,
removeKeyframe,
moveKeyframe,
setEasing,
commit,
registerKeyframeEl,
focusAdjacent,
focusKeyframe,
};
provideKeyframeTrackContext(context);
defineExpose({
sampleAt,
getValueCurve,
addKeyframe,
removeKeyframe,
moveKeyframe,
setEasing,
select,
keyframes,
projection,
invert,
});
// `useForwardExpose` runs AFTER `defineExpose` so it merges the prior bindings
// (plus props + `$el`) instead of clobbering them.
const { forwardRef } = useForwardExpose();
function setRootRef(el: unknown): void {
forwardRef(el as never);
rootEl.value = (el && typeof el === 'object' && '$el' in el ? (el as { $el: HTMLElement }).$el : el) as HTMLElement | null;
}
</script>
<template>
<Primitive
:ref="setRootRef"
:as="as"
:role="inTimeline ? 'listitem' : 'group'"
aria-roledescription="keyframe track"
:aria-label="ariaLabel"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:data-value-axis="valueAxis ? '' : undefined"
:data-in-timeline="inTimeline ? '' : undefined"
:data-mutating="isMutating ? '' : undefined"
data-orientation="horizontal"
:dir="direction"
>
<slot
:keyframes="keyframes"
:selected-id="selectedModel"
:duration="duration"
:projection="projection"
:sample-at="sampleAt"
/>
</Primitive>
</template>
@@ -0,0 +1,123 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The interval between two adjacent keyframes — the visual + interactive
* representation of a segment's easing. It spans from the keyframe identified by
* `keyframeId` to the next keyframe in time order, and clicking it selects the
* starting keyframe (so the easing editor can edit this segment's curve).
*
* It is `role="presentation"` by default (decorative); the keyframes themselves
* are the focusable controls. When `samples` is set it also renders an SVG
* `<path>` of the eased value curve across the segment (sampled via the shared
* spline), so consumers get a ready-to-style preview of the easing.
*/
export interface KeyframeTrackSegmentProps extends PrimitiveProps {
/** The id of the keyframe that STARTS this segment. */
keyframeId: string;
/**
* When set, render an SVG path of the eased value curve sampled this many
* times across the segment (exposed as the `path` slot prop). @default 0
*/
samples?: number;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useKeyframeTrackContext } from './context';
const { keyframeId, samples = 0, as = 'div' } = defineProps<KeyframeTrackSegmentProps>();
const ctx = useKeyframeTrackContext();
// O(1) lookup via the root's memoized id → index map instead of an O(n)
// findIndex scan per segment on every drag frame.
const startIndex = computed(() => ctx.indexById.value.get(keyframeId) ?? -1);
const start = computed(() => ctx.keyframes.value[startIndex.value]);
const end = computed(() => {
const i = startIndex.value;
return i === -1 ? undefined : ctx.keyframes.value[i + 1];
});
const isSelected = computed(() => ctx.selectedId.value === keyframeId);
// Pixel span of the segment along the time axis.
const left = computed(() => (start.value ? ctx.projection(start.value.time) : 0));
const right = computed(() => (end.value ? ctx.projection(end.value.time) : left.value));
const width = computed(() => Math.abs(right.value - left.value));
const positionStyle = computed<{ left: string; width: string }>(() => ({
left: `${Math.min(left.value, right.value)}px`,
width: `${width.value}px`,
}));
// Optional SVG path of the eased value curve across the segment (lane-relative
// pixels). Uses the value projection in `valueAxis` mode; otherwise normalizes
// to the lane height so the easing shape is still previewable.
const path = computed<string>(() => {
const a = start.value;
const b = end.value;
const n = samples;
if (!a || !b || n < 2) return '';
const x0 = ctx.projection(a.time);
const x1 = ctx.projection(b.time);
const h = ctx.laneHeight.value || 1;
const valueAxis = ctx.valueAxis.value;
// Build each "M/L x,y" command into a packed array and join once, instead of
// repeated string concatenation per sample on the drag hot path.
const segs: string[] = [];
for (let i = 0; i < n; i++) {
const t = i / (n - 1);
const time = a.time + (b.time - a.time) * t;
const x = x0 + (x1 - x0) * t;
const value = ctx.sampleAt(time);
const y = valueAxis
? ctx.projectValue(value)
// Normalize value into the lane height (value-up) when there is no y-axis.
: h - normalize(value, a.value, b.value) * h;
segs.push(`${i === 0 ? 'M' : 'L'}${round(x)},${round(y)}`);
}
return segs.join(' ');
});
function normalize(value: number, a: number, b: number): number {
if (a === b) return 0.5;
const t = (value - a) / (b - a);
return Math.min(Math.max(t, 0), 1);
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
function onSelect(): void {
if (ctx.disabled.value) return;
ctx.select(keyframeId);
}
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
v-if="start && end"
:ref="forwardRef"
:as="as"
role="presentation"
data-segment
:data-selected="isSelected ? '' : undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:style="positionStyle"
@pointerdown="onSelect"
>
<slot
:start="start"
:end="end"
:selected="isSelected"
:width="width"
:path="path"
/>
</Primitive>
</template>
@@ -0,0 +1,261 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
KeyframeTrackEasingEditor,
KeyframeTrackKeyframe,
KeyframeTrackRoot,
} from '../index';
import type { KeyframeTrackKeyframeData } from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }));
}
interface MountOpts {
defaultValue?: KeyframeTrackKeyframeData[];
modelValue?: KeyframeTrackKeyframeData[];
property?: string;
valueAxis?: boolean;
valueRange?: [number, number];
duration?: number;
fps?: number;
step?: number;
allowOverlap?: boolean;
disabled?: boolean;
selectedId?: string | null;
}
function mountTrack(opts: MountOpts = {}, withEasing = false) {
const model = ref<KeyframeTrackKeyframeData[] | undefined>(opts.modelValue);
const selected = ref<string | null>(opts.selectedId ?? null);
const commits: string[] = [];
const Harness = defineComponent({
setup() {
// Cast to `any` for the `h()` call: vue-tsc cannot resolve the `h` overload
// for a `defineModel` component passed an inline props object (same pattern
// the accordion/checkbox suites use); the runtime props are correct.
return () => h(KeyframeTrackRoot as any, {
modelValue: model.value,
'onUpdate:modelValue': (v: KeyframeTrackKeyframeData[]) => { model.value = v; },
selectedId: selected.value,
'onUpdate:selectedId': (v: string | null) => { selected.value = v; },
onKeyframeCommit: (id: string) => { commits.push(id); },
defaultValue: opts.defaultValue,
property: opts.property,
valueAxis: opts.valueAxis,
valueRange: opts.valueRange,
duration: opts.duration,
fps: opts.fps,
step: opts.step,
allowOverlap: opts.allowOverlap,
disabled: opts.disabled,
style: 'width: 300px; height: 40px; position: relative; display: block;',
}, {
default: ({ keyframes }: { keyframes: KeyframeTrackKeyframeData[] }) => [
...keyframes.map(k => h(KeyframeTrackKeyframe, { key: k.id, keyframeId: k.id, id: `kf-${k.id}` })),
...(withEasing ? [h(KeyframeTrackEasingEditor)] : []),
],
});
},
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, model, selected, commits };
}
const TWO = (): KeyframeTrackKeyframeData[] => [
{ id: 'a', time: 0, value: 0, easing: [0, 0, 1, 1] },
{ id: 'b', time: 1, value: 1 },
];
describe('KeyframeTrack — rendering', () => {
it('standalone root is a group with keyframe-track roledescription', async () => {
mountTrack({ defaultValue: TWO(), property: 'opacity' });
await nextTick();
const root = document.querySelector('[aria-roledescription="keyframe track"]')!;
expect(root).toBeTruthy();
expect(root.getAttribute('role')).toBe('group');
expect(root.getAttribute('aria-label')).toBe('opacity keyframes');
});
it('renders each keyframe as role="slider" with seconds aria-valuetext announcing the property + value', async () => {
mountTrack({ defaultValue: TWO(), property: 'opacity' });
await nextTick();
const sliders = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(sliders).toHaveLength(2);
// aria-valuenow is the TIME in seconds (default, non-valueAxis).
expect(sliders[0]!.getAttribute('aria-valuenow')).toBe('0');
expect(sliders[1]!.getAttribute('aria-valuenow')).toBe('1');
// aria-valuetext leads with the formatted time then property + value.
expect(sliders[0]!.getAttribute('aria-valuetext')).toContain('opacity 0');
expect(sliders[1]!.getAttribute('aria-valuetext')).toContain('opacity 1');
expect(sliders[0]!.getAttribute('aria-orientation')).toBe('horizontal');
});
});
describe('KeyframeTrack — keyboard', () => {
it('ArrowRight nudges the keyframe forward by one frame (neighbour-clamped)', async () => {
const { model } = mountTrack({ defaultValue: TWO(), fps: 30 });
await nextTick();
const first = document.getElementById('kf-a')!;
keydown(first, 'ArrowRight');
await nextTick();
const a = model.value!.find(k => k.id === 'a')!;
expect(a.time).toBeCloseTo(1 / 30, 6);
});
it('ArrowRight does not cross the next keyframe unless allowOverlap', async () => {
mountTrack({
defaultValue: [
{ id: 'a', time: 0, value: 0 },
{ id: 'b', time: 5 / 30, value: 1 },
],
fps: 30,
});
await nextTick();
const first = document.getElementById('kf-a')!;
// Push hard into b; neighbour-clamp keeps a strictly before b by minTimeBetween (1 frame).
for (let i = 0; i < 20; i++) keydown(first, 'ArrowRight');
await nextTick();
// aria-valuenow always reflects the live time (seconds), even when clamped.
const aTime = Number(first.getAttribute('aria-valuenow'));
const bTime = Number(document.getElementById('kf-b')!.getAttribute('aria-valuenow'));
expect(aTime).toBeLessThan(bTime);
// Clamped exactly one frame before b.
expect(aTime).toBeCloseTo(4 / 30, 6);
});
it('ArrowLeft nudges backward and clamps at 0', async () => {
mountTrack({ defaultValue: [{ id: 'a', time: 2 / 30, value: 0 }, { id: 'b', time: 1, value: 1 }], fps: 30 });
await nextTick();
const first = document.getElementById('kf-a')!;
keydown(first, 'ArrowLeft');
await nextTick();
expect(Number(first.getAttribute('aria-valuenow'))).toBeCloseTo(1 / 30, 6);
keydown(first, 'ArrowLeft');
keydown(first, 'ArrowLeft');
await nextTick();
expect(Number(first.getAttribute('aria-valuenow'))).toBe(0);
});
it('ArrowUp/ArrowDown change the value in valueAxis mode', async () => {
const { model } = mountTrack({ defaultValue: TWO(), valueAxis: true, valueRange: [0, 1] });
await nextTick();
const first = document.getElementById('kf-a')!;
keydown(first, 'ArrowUp');
await nextTick();
expect(model.value!.find(k => k.id === 'a')!.value).toBeCloseTo(0.01, 6);
keydown(first, 'ArrowDown');
keydown(first, 'ArrowDown');
await nextTick();
expect(model.value!.find(k => k.id === 'a')!.value).toBeCloseTo(0, 6);
});
it('Home/End jump to min / max time (neighbour-clamped)', async () => {
const { model } = mountTrack({ defaultValue: TWO(), duration: 1, fps: 30 });
await nextTick();
const second = document.getElementById('kf-b')!;
keydown(second, 'Home');
await nextTick();
// b is neighbour-clamped one frame after a (time 0).
expect(model.value!.find(k => k.id === 'b')!.time).toBeCloseTo(1 / 30, 6);
keydown(second, 'End');
await nextTick();
expect(model.value!.find(k => k.id === 'b')!.time).toBeCloseTo(1, 6);
});
it('Delete removes the keyframe', async () => {
const { model } = mountTrack({ defaultValue: TWO() });
await nextTick();
const first = document.getElementById('kf-a')!;
keydown(first, 'Delete');
await nextTick();
expect(model.value!.map(k => k.id)).toEqual(['b']);
});
it('disabled: keys do nothing and tabindex is -1', async () => {
const { model } = mountTrack({ defaultValue: TWO(), disabled: true });
await nextTick();
const first = document.getElementById('kf-a')!;
expect(first.tabIndex).toBe(-1);
expect(first.getAttribute('aria-disabled')).toBe('true');
keydown(first, 'ArrowRight');
keydown(first, 'Delete');
await nextTick();
// unchanged (model never written, stays the seeded uncontrolled value).
expect(model.value).toBeUndefined();
});
});
describe('KeyframeTrack — selection', () => {
it('focus selects the keyframe and marks it selected', async () => {
const { selected } = mountTrack({ defaultValue: TWO() });
await nextTick();
const second = document.getElementById('kf-b')!;
second.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
await nextTick();
expect(selected.value).toBe('b');
expect(second.getAttribute('aria-selected')).toBe('true');
});
});
describe('KeyframeTrack — sampling expose', () => {
it('sampleAt returns the eased value between two keyframes (linear midpoint ≈ average)', async () => {
const { wrapper } = mountTrack({ defaultValue: TWO() });
await nextTick();
const root = wrapper.findComponent(KeyframeTrackRoot);
const sampleAt = (root.vm as any).sampleAt as (t: number) => number;
expect(sampleAt(0.5)).toBeCloseTo(0.5, 6);
expect(sampleAt(-1)).toBe(0);
expect(sampleAt(5)).toBe(1);
});
it('addKeyframe / removeKeyframe via the exposed API mutate the model', async () => {
const { wrapper, model } = mountTrack({ defaultValue: TWO() });
await nextTick();
const root = wrapper.findComponent(KeyframeTrackRoot);
const id = (root.vm as any).addKeyframe(0.5) as string;
await nextTick();
expect(model.value!.some(k => k.id === id)).toBe(true);
expect(model.value!.find(k => k.id === id)!.value).toBeCloseTo(0.5, 6);
(root.vm as any).removeKeyframe(id);
await nextTick();
expect(model.value!.some(k => k.id === id)).toBe(false);
});
});
describe('KeyframeTrack — easing editor', () => {
it('embeds a CurveEditor for the selected keyframe and setEasing updates it', async () => {
const { wrapper, model, selected } = mountTrack({ defaultValue: TWO(), selectedId: 'a' }, true);
await nextTick();
// The selected keyframe (a) has a following segment → the editor renders a CurveEditor.
expect(selected.value).toBe('a');
expect(document.querySelector('[data-easing-editor]')).toBeTruthy();
expect(document.querySelector('[data-interpolation="bezier"]')).toBeTruthy();
// Drive setEasing directly through the context-backed API.
const root = wrapper.findComponent(KeyframeTrackRoot);
(root.vm as any).setEasing('a', [0.42, 0, 0.58, 1]);
await nextTick();
expect(model.value!.find(k => k.id === 'a')!.easing).toEqual([0.42, 0, 0.58, 1]);
});
it('renders no editor when the selected keyframe is the last (no following segment)', async () => {
mountTrack({ defaultValue: TWO(), selectedId: 'b' }, true);
await nextTick();
expect(document.querySelector('[data-interpolation="bezier"]')).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_KEYFRAME_EASING } from '../context';
import type { KeyframeTrackKeyframeData } from '../context';
import {
clampKeyframeTime,
defaultKeyframeValueText,
sampleKeyframes,
snapTimeToFrame,
sortKeyframes,
} from '../utils';
function kf(id: string, time: number, value: number, easing?: [number, number, number, number]): KeyframeTrackKeyframeData {
return easing ? { id, time, value, easing } : { id, time, value };
}
describe('sortKeyframes', () => {
it('sorts ascending by time without mutating the input', () => {
const input = [kf('b', 2, 1), kf('a', 0, 0), kf('c', 1, 0.5)];
const sorted = sortKeyframes(input);
expect(sorted.map(k => k.id)).toEqual(['a', 'c', 'b']);
// input untouched
expect(input.map(k => k.id)).toEqual(['b', 'a', 'c']);
});
it('breaks ties deterministically on id', () => {
const sorted = sortKeyframes([kf('z', 1, 0), kf('a', 1, 1)]);
expect(sorted.map(k => k.id)).toEqual(['a', 'z']);
});
});
describe('sampleKeyframes', () => {
it('returns the single value for 0 / 1 keyframes', () => {
expect(sampleKeyframes([], 5)).toBe(0);
expect(sampleKeyframes([kf('a', 2, 0.7)], 100)).toBe(0.7);
expect(sampleKeyframes([kf('a', 2, 0.7)], -100)).toBe(0.7);
});
it('holds constant outside the keyframe range', () => {
const ks = [kf('a', 1, 0), kf('b', 3, 1)];
expect(sampleKeyframes(ks, 0)).toBe(0);
expect(sampleKeyframes(ks, 1)).toBe(0);
expect(sampleKeyframes(ks, 3)).toBe(1);
expect(sampleKeyframes(ks, 10)).toBe(1);
});
it('linear easing midpoint ≈ the average of the two values', () => {
// DEFAULT_KEYFRAME_EASING is a linear ramp.
const ks = [kf('a', 0, 0, [...DEFAULT_KEYFRAME_EASING] as [number, number, number, number]), kf('b', 2, 1)];
expect(sampleKeyframes(ks, 1)).toBeCloseTo(0.5, 6);
expect(sampleKeyframes(ks, 0.5)).toBeCloseTo(0.25, 6);
});
it('an ease curve is off-center at the midpoint (vs linear)', () => {
// ease-in: cubic-bezier(0.42, 0, 1, 1) starts slow → midpoint below 0.5.
const ks = [kf('a', 0, 0, [0.42, 0, 1, 1]), kf('b', 2, 1)];
const mid = sampleKeyframes(ks, 1);
expect(mid).toBeLessThan(0.5);
expect(mid).toBeGreaterThan(0);
});
it('ease-out is above 0.5 at the midpoint', () => {
// ease-out: cubic-bezier(0, 0, 0.58, 1) ends slow → midpoint above 0.5.
const ks = [kf('a', 0, 0, [0, 0, 0.58, 1]), kf('b', 2, 1)];
expect(sampleKeyframes(ks, 1)).toBeGreaterThan(0.5);
});
});
describe('clampKeyframeTime', () => {
const ks = [kf('a', 0, 0), kf('b', 1, 0), kf('c', 2, 0)];
it('clamps to >= 0 and <= duration', () => {
expect(clampKeyframeTime(ks, 1, -5, { allowOverlap: true, minTimeBetween: 0 })).toBe(0);
expect(clampKeyframeTime(ks, 1, 99, { allowOverlap: true, minTimeBetween: 0, duration: 2 })).toBe(2);
});
it('neighbour-clamps with minTimeBetween when overlap is disallowed', () => {
// Moving b (index 1) far right stops minTimeBetween before c (time 2).
expect(clampKeyframeTime(ks, 1, 5, { allowOverlap: false, minTimeBetween: 0.25 })).toBe(1.75);
// Moving b far left stops minTimeBetween after a (time 0).
expect(clampKeyframeTime(ks, 1, -5, { allowOverlap: false, minTimeBetween: 0.25 })).toBe(0.25);
});
it('allows crossing neighbours when overlap is enabled', () => {
expect(clampKeyframeTime(ks, 1, 1.9, { allowOverlap: true, minTimeBetween: 0.25 })).toBe(1.9);
});
});
describe('snapTimeToFrame', () => {
it('quantizes to whole frames at fps', () => {
expect(snapTimeToFrame(0.51, 30)).toBeCloseTo(15 / 30, 6);
expect(snapTimeToFrame(0.49, 30)).toBeCloseTo(15 / 30, 6);
});
it('passes through when fps <= 0', () => {
expect(snapTimeToFrame(1.234, 0)).toBe(1.234);
});
});
describe('defaultKeyframeValueText', () => {
it('includes the property when present', () => {
expect(defaultKeyframeValueText(0.5, 'opacity')).toBe('opacity 0.5');
});
it('omits the property when absent', () => {
expect(defaultKeyframeValueText(0.5)).toBe('0.5');
});
});
@@ -0,0 +1,144 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
import type { Direction } from '../../utilities/config-provider';
import type { UseSnappingReturn } from '../../internal/snapping';
/**
* A single keyframe on the track's time axis.
*
* `time` is in seconds, `value` is the animated value (in `valueRange` space).
* `easing` is the cubic-bezier control tuple `[x1, y1, x2, y2]` for the segment
* that STARTS at this keyframe and runs to the next one in time order — the
* implicit anchors are `(0,0)` and `(1,1)` (CSS `cubic-bezier` semantics). When
* `easing` is absent the segment falls back to {@link DEFAULT_KEYFRAME_EASING}.
*/
export interface KeyframeTrackKeyframeData {
/** Stable identity used as the `v-for` key, roving-focus handle, and selection id. */
id: string;
/** Time of the keyframe in seconds. */
time: number;
/** The animated value at this keyframe (in `valueRange` space). */
value: number;
/** Cubic-bezier control points `[x1, y1, x2, y2]` for the segment starting here. */
easing?: [number, number, number, number];
}
/**
* Default easing for a segment with no explicit `easing` tuple: a linear ramp
* (`cubic-bezier(0, 0, 1, 1)`), so an un-eased segment interpolates straight.
*/
export const DEFAULT_KEYFRAME_EASING: readonly [number, number, number, number] = [0, 0, 1, 1];
/**
* Context shared between `KeyframeTrackRoot` and its parts.
*
* Scalar props are exposed as plain `Ref<T>` — `KeyframeTrackRoot` builds them
* with `toRef(() => prop)` (a reactive getter ref without an extra effect),
* matching the slider / timeline / curve-editor convention. `projection` /
* `invert` are stable closures safe on the pointer hot path.
*/
export interface KeyframeTrackContext {
/** Stable id base for scoping DOM ids per track instance. */
trackId: string;
/** The live keyframes, sorted ascending by `time`. */
keyframes: ComputedRef<KeyframeTrackKeyframeData[]>;
/**
* Memoized `id → array index` map over {@link keyframes}, rebuilt once per
* change. Parts use it for O(1) id lookup instead of an O(n) `find`/`findIndex`
* scan per part per frame (the whole-track cost stays O(n), not O(n²)).
*/
indexById: ComputedRef<Map<string, number>>;
/** Currently selected keyframe id (drives the easing editor + roving focus), or null. */
selectedId: Ref<string | null>;
/** The animated property name (for the a11y label). */
property: Ref<string | undefined>;
/** Whether keyframes move vertically to edit `value` (else single horizontal lane). */
valueAxis: Ref<boolean>;
/** Value domain `[min, max]` (the y-axis extent in `valueAxis` mode). */
valueRange: Ref<readonly [number, number]>;
/** Total track duration in seconds (auto / injected from a Timeline / explicit). */
duration: ComputedRef<number>;
/** Frame rate (timecode + frame snapping + keyboard nudge). */
fps: Ref<number>;
/** Keyboard nudge step in seconds. */
step: Ref<number>;
/** Large keyboard step in seconds (Shift+Arrow). */
largeStep: Ref<number>;
/** Value-axis keyboard nudge step (per Arrow Up/Down in `valueAxis` mode). */
valueStep: Ref<number>;
/** Whether keyframes may overlap in time (else neighbour-clamped to keep order). */
allowOverlap: Ref<boolean>;
/** Minimum time gap between neighbouring keyframes (seconds) when `allowOverlap` is false. */
minTimeBetween: Ref<number>;
/** Snapping master enable. */
snapping: Ref<boolean>;
/** Master interactivity / disabled switch. */
disabled: Ref<boolean>;
/** Resolved reading direction. */
direction: ComputedRef<Direction>;
/** Live width (px) of the lane; `projection` range is `[0, width]`. */
laneWidth: Ref<number>;
/** Live height (px) of the lane (used by `valueAxis` y-projection). */
laneHeight: Ref<number>;
// ── coordinate model ──────────────────────────────────────────────────────
/** Project a time (seconds) to a pixel offset in the lane. Stable identity. */
projection: (seconds: number) => number;
/** Invert a pixel offset back to a time (seconds). Stable identity. */
invert: (px: number) => number;
/** Project a value to a pixel offset on the y-axis (value-up). Stable identity. */
projectValue: (value: number) => number;
/** Invert a y pixel offset back to a value. Stable identity. */
invertValue: (px: number) => number;
/** Format a time (seconds) as a wall-clock string. */
formatTime: (seconds: number) => string;
/** Snap a candidate time to the nearest snap target (frame grid). */
snapTime: (seconds: number, exclude?: string) => number;
/** The shared snap engine (frame-grid targets). */
snapEngine: UseSnappingReturn;
// ── data access ───────────────────────────────────────────────────────────
/** True while a keyframe drag is in flight (blocks external sync clobber). */
isMutating: Readonly<Ref<boolean>>;
/** The id of the keyframe currently being dragged (or null). */
draggingId: Readonly<Ref<string | null>>;
/** Whether this track is nested inside a Timeline (renders as a `listitem`). */
inTimeline: boolean;
// ── sampling ──────────────────────────────────────────────────────────────
/**
* Sample the animated value at an arbitrary time: find the bracketing
* keyframes and apply the segment easing via the spline. Constant outside the
* keyframe range (and for 0 / 1 keyframes).
*/
sampleAt: (time: number) => number;
// ── selection ─────────────────────────────────────────────────────────────
/** Select a keyframe by id (null clears). */
select: (id: string | null) => void;
// ── mutation ──────────────────────────────────────────────────────────────
/** Insert a keyframe at `time` (value defaults to the sampled curve). Returns its id. */
addKeyframe: (time: number, value?: number) => string | undefined;
/** Remove a keyframe by id. */
removeKeyframe: (id: string) => void;
/** Move a keyframe (transient overlay while `mutating`; commit on settle). */
moveKeyframe: (id: string, time: number, value?: number, mutating?: boolean) => void;
/** Set the segment easing (cubic-bezier tuple) of the segment starting at `id`. */
setEasing: (id: string, bezier: [number, number, number, number]) => void;
/** Commit the in-flight transient mutation into the model (one commit). */
commit: () => void;
// ── roving focus registration ─────────────────────────────────────────────
/** Register a keyframe element for roving focus. */
registerKeyframeEl: (id: string, el: HTMLElement | null) => void;
/** Focus the next/prev keyframe in time order from `fromId` (roving). */
focusAdjacent: (fromId: string, direction: 1 | -1) => void;
/** Focus a keyframe element by id. */
focusKeyframe: (id: string) => void;
}
const ctx = useContextFactory<KeyframeTrackContext>('KeyframeTrackContext');
export const provideKeyframeTrackContext = ctx.provide;
export const useKeyframeTrackContext = ctx.inject;
@@ -0,0 +1,250 @@
<script setup lang="ts">
import type { KeyframeTrackKeyframeData } from '@robonen/primitives';
import {
CurveEditorHandle,
CurveEditorPoint,
KeyframeTrackEasingEditor,
KeyframeTrackKeyframe,
KeyframeTrackRoot,
KeyframeTrackSegment,
} from '@robonen/primitives';
import { computed, ref } from 'vue';
// Cubic-bezier easing tuples for the segment STARTING at each keyframe
// (CSS `cubic-bezier(x1, y1, x2, y2)` semantics).
const EASE_OUT: [number, number, number, number] = [0, 0, 0.2, 1];
const EASE_IN: [number, number, number, number] = [0.5, 0, 1, 1];
const EASE_IN_OUT: [number, number, number, number] = [0.65, 0, 0.35, 1];
const keyframes = ref<KeyframeTrackKeyframeData[]>([
{ id: 'k1', time: 0, value: 0, easing: EASE_OUT },
{ id: 'k2', time: 1.2, value: 1, easing: EASE_IN_OUT },
{ id: 'k3', time: 2.6, value: 0.35, easing: EASE_IN },
{ id: 'k4', time: 4, value: 0.9 },
]);
const selectedId = ref<string | null>('k2');
const duration = 4;
const fps = 30;
// Live sampler, mirrored from the Root's `sampleAt` slot prop, for the readout.
const sampleFn = ref<(time: number) => number>(() => 0);
// A scrubbing probe so the demo shows the sampled value at an arbitrary time.
const probeTime = ref(1.6);
const probeValue = computed(() => sampleFn.value(probeTime.value));
const selectedKeyframe = computed(() =>
keyframes.value.find(k => k.id === selectedId.value),
);
function easingLabel(e?: [number, number, number, number]): string {
if (!e) return 'linear';
return `cubic-bezier(${e.map(n => +n.toFixed(2)).join(', ')})`;
}
// Build an SVG polyline of the whole value curve in lane-local coordinates.
// `projection` maps time → px (x); we map value → px (y) ourselves so the curve
// fills the lane height regardless of the component's own (horizontal) lane mode.
function curvePath(projection: (t: number) => number, sample: (t: number) => number, width: number, height: number): string {
if (width <= 0) return '';
const pad = 10;
const usable = height - pad * 2;
const samples = 120;
let d = '';
for (let i = 0; i < samples; i++) {
const t = (i / (samples - 1)) * duration;
const x = projection(t);
const y = pad + (1 - sample(t)) * usable;
d += `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)} `;
}
return d.trim();
}
function yForValue(value: number, height: number): number {
const pad = 10;
return pad + (1 - value) * (height - pad * 2);
}
</script>
<template>
<div class="demo-card w-full max-w-xl space-y-4 rounded-card border border-border bg-bg p-5 text-fg shadow-(--shadow-card)">
<div class="flex items-baseline justify-between">
<div>
<h3 class="text-sm font-semibold text-fg">Opacity</h3>
<p class="text-xs text-fg-subtle">Animation keyframes with editable easing</p>
</div>
<span class="rounded-md bg-bg-inset px-2 py-1 font-mono text-xs tabular-nums text-fg-muted">
f({{ probeTime.toFixed(2) }}s) = {{ probeValue.toFixed(3) }}
</span>
</div>
<!-- the keyframe lane
The Root measures its OWN box (standalone), so it needs an explicit
height + relative positioning; the curve SVG, segments, and keyframe
diamonds all read pixel coords from that box. -->
<KeyframeTrackRoot
v-model="keyframes"
v-model:selected-id="selectedId"
property="opacity"
:duration="duration"
:fps="fps"
:value-range="[0, 1]"
class="block w-full touch-none select-none"
>
<template #default="{ projection, sampleAt }">
{{ (sampleFn = sampleAt, '') }}
<!-- the lane box: keyframes, curve, and segments position against it -->
<div class="relative h-40 w-full overflow-hidden rounded-card border border-border bg-bg-inset">
<!-- horizontal value gridlines (0 / 0.5 / 1) -->
<div class="pointer-events-none absolute inset-0">
<div
v-for="v in [0, 0.5, 1]"
:key="v"
class="absolute inset-x-0 flex items-center"
:style="{ top: `${yForValue(v, 160)}px` }"
>
<span class="w-full border-t border-dashed border-border" />
<span class="absolute left-1 -translate-y-1/2 font-mono text-[9px] text-fg-subtle">{{ v }}</span>
</div>
</div>
<!-- the eased value curve across all segments -->
<svg class="pointer-events-none absolute inset-0 size-full" aria-hidden="true" preserveAspectRatio="none">
<path
:d="`${curvePath(projection, sampleAt, 600, 160)} L 600,170 L 0,170 Z`"
fill="var(--color-accent)"
fill-opacity="0.08"
/>
<path
:d="curvePath(projection, sampleAt, 600, 160)"
fill="none"
stroke="var(--color-accent)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<!-- selectable segment bands (click selects the starting keyframe) -->
<KeyframeTrackSegment
v-for="kf in keyframes.slice(0, -1)"
:key="`seg-${kf.id}`"
:keyframe-id="kf.id"
class="absolute inset-y-0 cursor-pointer border-l border-border/60 transition-colors hover:bg-fg/[0.03] data-[selected]:bg-accent/8"
/>
<!-- draggable keyframe diamonds, parked on the curve -->
<KeyframeTrackKeyframe
v-for="kf in keyframes"
:key="kf.id"
v-slot="{ keyframe, selected, dragging }"
:keyframe-id="kf.id"
class="absolute z-10 cursor-grab outline-none active:cursor-grabbing"
:style="{ top: `${yForValue(kf.value, 160)}px`, transform: 'translate(-50%, -50%)' }"
>
<span
class="block size-3.5 rotate-45 rounded-[2px] border-2 bg-bg shadow-sm transition-transform"
:class="[
selected ? 'border-accent scale-125 ring-2 ring-accent/40' : 'border-border-strong',
dragging ? 'scale-125' : 'hover:scale-110',
]"
:style="selected ? { backgroundColor: 'var(--color-accent)' } : undefined"
/>
<span class="sr-only">Keyframe at {{ keyframe?.time.toFixed(2) }}s, value {{ keyframe?.value.toFixed(2) }}</span>
</KeyframeTrackKeyframe>
<!-- a draggable time probe so the sampled value is live -->
<input
v-model.number="probeTime"
type="range"
:min="0"
:max="duration"
:step="0.02"
aria-label="Sample time"
class="absolute inset-x-0 bottom-0 z-20 m-0 h-5 w-full cursor-ew-resize appearance-none bg-transparent"
>
<span
class="pointer-events-none absolute inset-y-0 z-10 w-px bg-fg-muted/60"
:style="{ left: `${projection(probeTime)}px` }"
/>
</div>
<!-- selected-segment easing editor INSIDE the Root so it receives the
KeyframeTrack context. Renders only when a keyframe with a
following segment is selected. -->
<div class="grid grid-cols-[1fr_auto] gap-4 mt-4">
<div class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-fg-muted">Selected keyframe</span>
<span v-if="selectedKeyframe" class="font-mono tabular-nums text-fg-subtle">
{{ selectedKeyframe.time.toFixed(2) }}s · {{ selectedKeyframe.value.toFixed(2) }}
</span>
</div>
<p class="text-xs text-fg-subtle">
Segment easing:
<code class="rounded bg-bg-inset px-1 py-0.5 font-mono text-[11px] text-fg-muted">{{ easingLabel(selectedKeyframe?.easing) }}</code>
</p>
<p class="text-xs text-fg-subtle">
Drag the diamonds to move keyframes; drag the bezier handles to retune
the easing of the selected segment. Arrow keys nudge the focused keyframe.
</p>
</div>
<!-- the easing curve for the selected segment (an embedded CurveEditor).
The editor measures its OWN box; CurveEditorPoint / CurveEditorHandle
position themselves in that box's pixel space and are draggable. -->
<KeyframeTrackEasingEditor
v-slot="{ anchors, sample }"
:samples="64"
class="relative size-28 shrink-0 touch-none select-none overflow-hidden rounded-card border border-border bg-bg-inset"
>
<!-- the eased curve, sampled across [0,1] (y flipped for screen coords) -->
<svg class="pointer-events-none absolute inset-0 size-full" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<line x1="0" y1="1" x2="1" y2="0" stroke="var(--color-border-strong)" stroke-width="0.012" stroke-dasharray="0.03 0.03" />
<polyline
:points="Array.from({ length: 33 }, (_, i) => {
const x = i / 32;
return `${x},${1 - sample(x)}`;
}).join(' ')"
fill="none"
stroke="var(--color-accent)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<!-- draggable bezier tangent handles -->
<CurveEditorHandle
v-for="a in anchors"
:key="`out-${a.id}`"
:anchor="a"
side="out"
class="absolute z-10 size-2.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-accent bg-bg outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring"
/>
<CurveEditorHandle
v-for="a in anchors"
:key="`in-${a.id}`"
:anchor="a"
side="in"
class="absolute z-10 size-2.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-accent bg-bg outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring"
/>
<!-- the two pinned (0,0) / (1,1) anchors -->
<CurveEditorPoint
v-for="a in anchors"
:key="a.id"
:anchor="a"
class="absolute z-20 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-sm border-2 border-fg-subtle bg-bg outline-none"
/>
</KeyframeTrackEasingEditor>
</div>
</template>
</KeyframeTrackRoot>
</div>
</template>
@@ -0,0 +1,29 @@
export { default as KeyframeTrackRoot } from './KeyframeTrackRoot.vue';
export type { KeyframeTrackRootEmits, KeyframeTrackRootProps } from './KeyframeTrackRoot.vue';
export { default as KeyframeTrackKeyframe } from './KeyframeTrackKeyframe.vue';
export type { KeyframeTrackKeyframeProps } from './KeyframeTrackKeyframe.vue';
export { default as KeyframeTrackSegment } from './KeyframeTrackSegment.vue';
export type { KeyframeTrackSegmentProps } from './KeyframeTrackSegment.vue';
export { default as KeyframeTrackEasingEditor } from './KeyframeTrackEasingEditor.vue';
export type { KeyframeTrackEasingEditorProps } from './KeyframeTrackEasingEditor.vue';
export {
DEFAULT_KEYFRAME_EASING,
provideKeyframeTrackContext,
useKeyframeTrackContext,
} from './context';
export type {
KeyframeTrackContext,
KeyframeTrackKeyframeData,
} from './context';
export {
clampKeyframeTime,
defaultKeyframeValueText,
sampleKeyframes,
snapTimeToFrame,
sortKeyframes,
} from './utils';
@@ -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}`;
}