fix(studio): remove keyframe dragging from the timeline (#1763)

Dragging timeline keyframe diamonds was unreliable — clip<->tween percentage
remapping, an optimistic-hold workaround, and an intermittent no-op/revert when
the GSAP session lagged the drag (its own comments document the flakiness). Remove
the drag interaction entirely: diamonds still display, click-to-seek, and offer the
context menu (add/remove/ease) — keyframe timing is edited via the playhead + panel,
which are deterministic. Deletes the keyframe-move plan module + its wiring through
TimelineClipDiamonds -> TimelineCanvas -> Timeline -> TimelineEditContext.
This commit is contained in:
Miguel Ángel
2026-06-27 11:33:14 -04:00
committed by GitHub
parent 2d3b19d255
commit cf8f8aa568
8 changed files with 4 additions and 448 deletions
@@ -21,8 +21,6 @@ import { useDomEditActionsContext, useDomEditSelectionContext } from "../context
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import { fetchParsedAnimations } from "../hooks/useGsapTweenCache";
import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
export interface StudioPreviewAreaProps {
@@ -182,45 +180,6 @@ export function StudioPreviewArea({
}
},
// fallow-ignore-next-line complexity
onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => {
// Resolve the dragged element's selection + parsed animations on demand
// (both awaited and cached) rather than relying on the async DOM-edit
// session being loaded for this element — that coupling made the commit
// intermittently no-op (revert) when dragging before the session caught up.
if (!projectId) return;
const sourceFile = _el.sourceFile || activeCompPath || "index.html";
const [selection, parsed] = await Promise.all([
buildDomSelectionForTimelineElement(_el),
fetchParsedAnimations(projectId, sourceFile),
]);
if (!selection || !parsed) return;
const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id);
const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
const origAbsTime = _el.start + (oldPct / 100) * _el.duration;
const anim = pickKeyframeTween(
parsed.animations,
_el,
origAbsTime,
cachedKf?.propertyGroup,
);
if (!anim) return;
const plan = computeKeyframeMovePlan(
anim,
cachedKf?.tweenPercentage ?? oldPct,
_el,
newPct,
);
if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection);
for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection);
for (const add of plan.adds) {
for (const [prop, val] of Object.entries(add.properties)) {
handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection);
}
}
},
// fallow-ignore-next-line complexity
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
const currentTime = usePlayerStore.getState().currentTime;
const pct =
@@ -1,101 +0,0 @@
import { describe, it, expect } from "vitest";
import { pickKeyframeTween, computeKeyframeMovePlan } from "./keyframeMove";
const flat = (id: string, target: string, position: number, duration: number, group?: string) => ({
id,
targetSelector: target,
position,
duration,
resolvedStart: position,
propertyGroup: group,
});
const el = { start: 0, duration: 10, domId: "box", selector: "#box" };
describe("pickKeyframeTween", () => {
it("matches by the element's selector", () => {
const anims = [flat("a", "#other", 0, 5), flat("b", "#box", 2, 3)];
expect(pickKeyframeTween(anims, el, 3, undefined)?.id).toBe("b");
});
it("prefers the dragged keyframe's property group", () => {
const anims = [flat("pos", "#box", 0, 8, "position"), flat("vis", "#box", 0, 8, "visual")];
expect(pickKeyframeTween(anims, el, 1, "visual")?.id).toBe("vis");
});
it("among same-group tweens picks the one whose window contains the original time", () => {
const fadeIn = flat("in", "#box", 1, 1, "visual");
const fadeOut = flat("out", "#box", 8, 1, "visual");
expect(pickKeyframeTween([fadeIn, fadeOut], el, 8.5, "visual")?.id).toBe("out");
expect(pickKeyframeTween([fadeIn, fadeOut], el, 1.2, "visual")?.id).toBe("in");
});
it("returns undefined when there are no tweens", () => {
expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined();
});
it("returns undefined rather than editing another element on a selector mismatch", () => {
const anims = [flat("a", "#other", 0, 5), flat("b", ".unrelated", 2, 3)];
expect(pickKeyframeTween(anims, el, 3, undefined)).toBeUndefined();
});
});
describe("computeKeyframeMovePlan — flat tween", () => {
const anim = flat("t", "#box", 2, 4); // window [2, 6]
it("start point trims the front, keeping the end fixed", () => {
// newPct 30% → abs 3 → start moves to 3, duration shrinks to 3.
const plan = computeKeyframeMovePlan(anim, 0, el, 30);
expect(plan.meta).toEqual({ position: 3, duration: 3 });
expect(plan.removes).toEqual([]);
});
it("end point resizes, keeping the start", () => {
// tweenOldPct 100 (end) → newPct 80% → abs 8 → duration 6, start unchanged.
const plan = computeKeyframeMovePlan(anim, 100, el, 80);
expect(plan.meta).toEqual({ position: 2, duration: 6 });
});
});
describe("computeKeyframeMovePlan — keyframe-array tween", () => {
const anim = {
id: "k",
targetSelector: "#box",
position: 0,
duration: 10,
resolvedStart: 0,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 50 } },
{ percentage: 100, properties: { x: 100 } },
],
},
};
it("moves an intermediate keyframe without touching the tween or others", () => {
// mid keyframe (tweenPct 50) → newPct 70% → abs 7 → 70% of the tween.
const plan = computeKeyframeMovePlan(anim, 50, el, 70);
expect(plan.meta).toBeUndefined();
expect(plan.removes).toEqual([50]);
expect(plan.adds).toEqual([{ pct: 70, properties: { x: 50 } }]);
});
it("start move remaps intermediates to preserve their absolute times", () => {
// start (tweenPct 0) → newPct 20% → abs 2 → window [2,10]. The 50% keyframe
// was at abs 5 → now (5-2)/8 = 37.5%.
const plan = computeKeyframeMovePlan(anim, 0, el, 20);
expect(plan.meta).toEqual({ position: 2, duration: 8 });
expect(plan.removes).toContain(50);
const mid = plan.adds.find((a) => a.properties.x === 50);
expect(mid?.pct).toBeCloseTo(37.5, 1);
});
it("is a no-op when the dragged keyframe can't be located (stale cache)", () => {
// tweenOldPct 33 matches no keyframe (0/50/100) → must NOT resize the tween.
const plan = computeKeyframeMovePlan(anim, 33, el, 70);
expect(plan.meta).toBeUndefined();
expect(plan.removes).toEqual([]);
expect(plan.adds).toEqual([]);
});
});
@@ -1,151 +0,0 @@
/**
* Pure helpers for committing a keyframe-diamond drag: pick the tween the
* dragged keyframe belongs to, and compute the GSAP mutations (tween
* position/duration and/or keyframe add/remove) for the move. Kept free of
* React/store so the timeline drag handler stays a thin orchestrator.
*/
interface TweenLike {
id: string;
targetSelector: string;
position: number | string;
duration?: number;
resolvedStart?: number;
propertyGroup?: string;
keyframes?: { keyframes: { percentage: number; properties: Record<string, number | string> }[] };
}
interface ElementWindow {
start: number;
duration: number;
domId?: string;
selector?: string;
}
export interface KeyframeMovePlan {
/** Tween timing change (start/end point drags). */
meta?: { position: number; duration: number };
/** Keyframe percentages to remove, then re-add (intermediate move / remap). */
removes: number[];
adds: { pct: number; properties: Record<string, number | string> }[];
}
const round3 = (n: number) => Math.round(n * 1000) / 1000;
const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100));
const MIN_DUR = 0.05;
function tweenWindow(a: TweenLike): { start: number; dur: number } {
return {
start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0),
dur: a.duration ?? 0,
};
}
type Kf = { percentage: number; properties: Record<string, number | string> };
/**
* Remap every keyframe except `keepIdx` from the old tween window to the new one
* so their absolute times stay fixed after a start/end resize. Returns the
* remove/add ops (empty for flat tweens, which have no intermediates).
*/
function remapKeyframes(
kfs: Kf[],
keepIdx: number,
oldStart: number,
oldDur: number,
newStart: number,
newDur: number,
): Pick<KeyframeMovePlan, "removes" | "adds"> {
const removes: number[] = [];
const adds: KeyframeMovePlan["adds"] = [];
if (newDur <= 0) return { removes, adds };
for (let i = 0; i < kfs.length; i++) {
if (i === keepIdx) continue;
const k = kfs[i]!;
const absT = oldStart + (k.percentage / 100) * oldDur;
const remapped = clampPct(((absT - newStart) / newDur) * 100);
if (Math.abs(remapped - k.percentage) < 0.05) continue;
removes.push(k.percentage);
adds.push({ pct: remapped, properties: k.properties });
}
return { removes, adds };
}
/**
* Pick the tween the dragged keyframe belongs to: restrict to the element's
* selector and (if known) the keyframe's property group, then choose the one
* whose time window contains — or is nearest — the keyframe's original time.
* An element can have several tweens in one group (e.g. fade-in + fade-out).
*/
export function pickKeyframeTween<T extends TweenLike>(
anims: T[],
el: ElementWindow,
origAbsTime: number,
group: string | undefined,
): T | undefined {
const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean);
const forEl = anims.filter((a) => selectors.includes(a.targetSelector));
// Only ever pick among THIS element's tweens. Don't fall back to all
// animations — a selector mismatch (e.g. a class/compound-selector tween)
// would otherwise edit a different element's keyframes. No match → no-op.
if (forEl.length === 0) return undefined;
const groupPool = group ? forEl.filter((a) => a.propertyGroup === group) : [];
const candidates = groupPool.length > 0 ? groupPool : forEl;
const dist = (a: T): number => {
const { start, dur } = tweenWindow(a);
if (origAbsTime >= start && origAbsTime <= start + dur) return 0;
return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur)));
};
return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!);
}
/**
* Compute the mutations for moving a keyframe to `newPct` (clip-relative):
* - start point → trim front (position moves, end fixed),
* - end point → resize (duration changes, start fixed),
* - intermediate → move only that keyframe; start/end moves remap the other
* keyframes so their absolute times stay put.
*/
// fallow-ignore-next-line complexity
export function computeKeyframeMovePlan(
anim: TweenLike,
tweenOldPct: number,
el: ElementWindow,
newPct: number,
): KeyframeMovePlan {
const newAbsTime = el.start + (newPct / 100) * el.duration;
const tweenStart = tweenWindow(anim).start;
const tweenDur = anim.duration ?? el.duration;
const kfs = anim.keyframes
? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage)
: null;
const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1;
// Keyframe-array tween but the dragged keyframe couldn't be located (stale
// cache / precision drift): no-op rather than falling through to an end-point
// resize that would silently rescale the whole tween and re-time every key.
if (kfs && idx === -1) return { removes: [], adds: [] };
if (kfs && idx > 0 && idx < kfs.length - 1) {
const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0;
return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] };
}
const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50;
let newStart = tweenStart;
let newDur = tweenDur;
if (isStartPoint) {
const end = tweenStart + tweenDur;
newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR));
newDur = end - newStart;
} else {
newDur = Math.max(MIN_DUR, newAbsTime - tweenStart);
}
const windowChanged = newStart !== tweenStart || newDur !== tweenDur;
const remap =
kfs && windowChanged
? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur)
: { removes: [], adds: [] };
return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap };
}
@@ -39,7 +39,6 @@ export function TimelineEditProvider({
value.onDeleteKeyframe,
value.onDeleteAllKeyframes,
value.onChangeKeyframeEase,
value.onMoveKeyframe,
value.onToggleKeyframeAtPlayhead,
],
);
@@ -20,7 +20,6 @@ import {
type KeyframeDiamondContextMenuState,
} from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { snapKeyframePctToBeat } from "./timelineEditing";
import { ClipContextMenu } from "./ClipContextMenu";
import {
GUTTER,
@@ -87,7 +86,6 @@ export const Timeline = memo(function Timeline({
onDeleteKeyframe,
onDeleteAllKeyframes,
onChangeKeyframeEase,
onMoveKeyframe,
} = useResolvedTimelineEditCallbacks({
onMoveElement: onMoveElementOverride,
onResizeElement: onResizeElementOverride,
@@ -481,19 +479,6 @@ export const Timeline = memo(function Timeline({
onShiftClickKeyframe={(elId, pct) => {
toggleSelectedKeyframe(`${elId}:${pct}`);
}}
onDragKeyframe={(el, oldPct, newPct) => {
onMoveKeyframe?.(el, oldPct, newPct);
}}
onSnapKeyframePct={(el, pct) =>
snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps)
}
onPickKeyframeElement={(el) => {
const elKey = el.key ?? el.id;
if (selectedElementId !== elKey) {
setSelectedElementId(elKey);
onSelectElement?.(el);
}
}}
onContextMenuKeyframe={(e, elId, pct) => {
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
if (el) {
@@ -91,11 +91,6 @@ interface TimelineCanvasProps {
currentTime: number;
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
/** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */
onSnapKeyframePct?: (element: TimelineElement, pct: number) => number;
/** Select the element when a keyframe drag starts (loads its GSAP session). */
onPickKeyframeElement?: (element: TimelineElement) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
beatAnalysis?: MusicBeatAnalysis | null;
@@ -143,9 +138,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
currentTime,
onClickKeyframe,
onShiftClickKeyframe,
onDragKeyframe,
onSnapKeyframePct,
onPickKeyframeElement,
onContextMenuKeyframe,
onContextMenuClip,
beatAnalysis,
@@ -446,11 +438,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
onShiftClickKeyframe={onShiftClickKeyframe}
onDragKeyframe={(oldPct, newPct) =>
onDragKeyframe?.(previewElement, oldPct, newPct)
}
snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct}
onPickForDrag={() => onPickKeyframeElement?.(previewElement)}
onContextMenuKeyframe={onContextMenuKeyframe}
/>
)}
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from "react";
import { memo } from "react";
import { BEAT_BAND_H } from "./BeatStrip";
interface KeyframeEntry {
@@ -28,15 +28,7 @@ interface TimelineClipDiamondsProps {
selectedKeyframes: Set<string>;
onClickKeyframe?: (percentage: number) => void;
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
onDragKeyframe?: (percentage: number, newPercentage: number) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
/** Snap a clip-relative percentage to the nearest beat (returns it unchanged
* when no beat is within range). Drives live beat-snapping while dragging. */
snapPct?: (percentage: number) => number;
/** Select this element when a keyframe drag begins, so its GSAP session is
* loaded by the time the move commits (diamonds render on unselected clips
* too, and a drag suppresses the selecting click). */
onPickForDrag?: () => void;
}
const DIAMOND_RATIO = 0.8;
@@ -59,54 +51,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
selectedKeyframes,
onClickKeyframe,
onShiftClickKeyframe,
onDragKeyframe,
onContextMenuKeyframe,
snapPct,
onPickForDrag,
}: TimelineClipDiamondsProps) {
// Live drag: which keyframe (by original %) is being dragged and its current
// (beat-snapped) %, so the diamond + its connecting lines follow the cursor.
const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null);
const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null);
// Commit through the latest callback, not the one captured at pointer-down:
// selecting the element on drag-start loads its GSAP session asynchronously,
// and the commit must use the closure that sees the loaded session.
const onDragKeyframeRef = useRef(onDragKeyframe);
onDragKeyframeRef.current = onDragKeyframe;
// Optimistic hold: after a commit, keep the diamond at the dropped position
// until the cache reflects the change (the file round-trip rewrites
// keyframesData), so it doesn't flash back to the old spot in between.
const pendingRef = useRef(false);
const pendingHeldPctRef = useRef<number | null>(null);
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Cleanup for an in-flight drag's document listeners, so an unmount mid-drag
// (clip deleted, comp switch, zoom-out → early return) doesn't leak them.
const dragCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!pendingRef.current) return;
// Only release the optimistic hold once the cache actually reflects the
// committed position (a keyframe near the held %). An unrelated cache
// rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME
// percentages — releasing then would flash the diamond back to the old spot.
const held = pendingHeldPctRef.current;
if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) {
return;
}
pendingRef.current = false;
pendingHeldPctRef.current = null;
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
setDrag(null);
}, [keyframesData]);
useEffect(
() => () => {
clearTimeout(pendingTimerRef.current ?? undefined);
dragCleanupRef.current?.();
},
[],
);
if (clipWidthPx < 20) return null;
// When the beat strip occupies the top band, shrink the diamonds and center
@@ -129,79 +75,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
}
};
const handlePointerDown = (e: React.PointerEvent, pct: number) => {
if (e.button !== 0) return;
e.stopPropagation();
// Ignore a new drag while a prior drop is still settling: `pct` comes from
// props (the pre-drop position) but the diamond is held at its dropped spot
// via effPct(), so a re-grab would track from a stale origin and commit
// against the wrong tween. The hold clears on the cache round-trip (≤2s).
if (pendingRef.current) return;
// Select the element up front so its GSAP session loads during the drag and
// the commit (which resolves the animation from the selection) isn't a no-op.
onPickForDrag?.();
const startX = e.clientX;
dragRef.current = { origPct: pct, pct, moved: false };
const handleMove = (me: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = me.clientX - startX;
// 4px dead zone so a click doesn't register as a drag.
if (!d.moved && Math.abs(dx) <= 4) return;
d.moved = true;
const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100));
const snapped = snapPct ? snapPct(rawPct) : rawPct;
d.pct = snapped;
setDrag({ origPct: pct, pct: snapped });
};
const handleUp = () => {
document.removeEventListener("pointermove", handleMove);
document.removeEventListener("pointerup", handleUp);
dragCleanupRef.current = null;
const d = dragRef.current;
dragRef.current = null;
const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5);
if (willCommit && d) {
// Hold the dropped position optimistically; the effect clears it once the
// cache round-trip lands (fallback timeout in case it never does).
pendingRef.current = true;
pendingHeldPctRef.current = d.pct;
setDrag({ origPct: d.origPct, pct: d.pct });
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = setTimeout(() => {
pendingRef.current = false;
pendingHeldPctRef.current = null;
setDrag(null);
}, 2000);
onDragKeyframeRef.current?.(d.origPct, d.pct);
} else {
setDrag(null);
}
};
dragCleanupRef.current = () => {
document.removeEventListener("pointermove", handleMove);
document.removeEventListener("pointerup", handleUp);
};
document.addEventListener("pointermove", handleMove);
document.addEventListener("pointerup", handleUp);
};
const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
return (
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
{sorted.map((kf, i) => {
if (i === 0) return null;
const prev = sorted[i - 1]!;
const x1 = Math.max(
0,
Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx),
);
const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx));
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
if (x2 - x1 < 1) return null;
return (
<div
@@ -227,7 +107,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
// is the clip's left edge (the diamond's left half overflows, which the
// overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
// boundary keyframes (0% / 100%) would render off-center.
const leftPx = (effPct(kf.percentage) / 100) * clipWidthPx - half;
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
const kfKey = `${elementId}:${kf.percentage}`;
const isKfSelected = selectedKeyframes.has(kfKey);
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
@@ -252,7 +132,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
padding: 0,
}}
onClick={(e) => handleClick(e, kf.percentage)}
onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -39,6 +39,5 @@ export interface TimelineEditCallbacks {
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
}