Files
hyperframes/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx
T
Miguel Ángel b403c54ae7 feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782) (#1784)
* feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes #1782)

Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture
to retime an existing keyframe while preserving its value and easing (delete+re-add
bakes computed values and drops the explicit ease). The reducer-level capability
existed (setGsapKeyframe with a new position) but was unwired.

Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and
recast, in parity) that re-keys a keyframe to a new percentage, carrying its
properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to
Playhead' entry on the keyframe context menu through both hosts (canvas
MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the
playhead's tween-relative percentage.

Tests: parser correctness + recast/acorn parity (value+ease preserved, collision
overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt
clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow
health gate (parity-twin + wiring-layer duplication; extracted helper).

* feat(studio): restore drag-to-retime on timeline keyframes

Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic
move-keyframe foundation so it's reliable. #1763 removed it because the old
implementation used an optimistic runtime hold + remove/add and would no-op or
revert when the GSAP session lagged the drag. This version:

- previews visual-only (the dragged diamond follows the pointer; nothing touches
  the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves
  value + ease) — no optimistic hold, no lag race.
- pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion,
  clamp [0,100], no-op when drop==origin (unit-tested).
- wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline →
  TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the
  dragged keyframe's animation via resolveKeyframeTarget.

tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate
(same parity/wiring duplication as the rest of the branch).

* feat(studio): complete keyframe-drag UX — neighbor clamp + boundary resize

Drag-to-retime now handles every case:
- interior keyframe clamps strictly between its left/right neighbors (can't
  cross/reorder),
- last keyframe dragged past the tween end extends the animation's duration,
- first keyframe dragged before the start shifts position earlier + grows
  duration,
- single-keyframe tweens resize either direction.

Boundary extends remap the other keyframes to preserve their absolute times
(value + per-keyframe ease copied through) via the atomic replace-with-keyframes
mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits
on drop — no optimistic runtime hold.

Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) +
keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea
resolves the tween window + clip timing and dispatches move vs resize.

tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass
(22 new helper tests). Flat keyframe-less tweens still move within window;
boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate.

* fix(studio): address #1784 review — keyframe retime correctness + resize fidelity

Round 2 from Via + Rames:
- (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache
  lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on
  any tween shorter than its clip (Move to Playhead + the inherited Delete silently
  no-op'd). Menu now passes clip-%.
- boundary resize preserved author intent: new record-preserving parser op
  resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf
  ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes
  which dropped them.
- resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent
  (retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget.
- moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal
  (<0.05), collision only vs a different keyframe.
- soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate
  resolves stale position-encoded ids).

Tests: parser parity (small move + resize round-trip fidelity), studio-server
resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths.
735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed
fallow gate (branch-wide parity/wiring duplication).
2026-06-29 14:43:07 -07:00

88 lines
2.9 KiB
TypeScript

import { memo } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
export interface KeyframeDiamondContextMenuState {
x: number;
y: number;
elementId: string;
percentage: number;
tweenPercentage?: number;
currentEase?: string;
}
interface KeyframeDiamondContextMenuProps {
state: KeyframeDiamondContextMenuState;
onClose: () => void;
onDelete: (elementId: string, percentage: number) => void;
onDeleteAll: (elementId: string) => void;
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
onCopyProperties?: (elementId: string, percentage: number) => void;
/** Retime the keyframe to the current playhead, preserving its value + ease. */
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
}
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
state,
onClose,
onDelete,
onDeleteAll,
onMoveToPlayhead,
}: KeyframeDiamondContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
const menuWidth = 200;
const menuHeight = onMoveToPlayhead ? 100 : 70;
const overflowY = state.y + menuHeight - window.innerHeight;
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
>
{onMoveToPlayhead && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
// and returns the tween-% for the mutation. Passing tween-% here would
// miss the lookup on any tween whose window is shorter than the clip.
onMoveToPlayhead(state.elementId, state.percentage);
onClose();
}}
>
Move to Playhead
</button>
)}
{/* Delete */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onDelete(state.elementId, state.percentage);
onClose();
}}
>
Delete Keyframe
</button>
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onDeleteAll(state.elementId);
onClose();
}}
>
Delete All Keyframes
</button>
</div>,
document.body,
);
});