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,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();
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user