mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(studio): close the timeline regressions the QA triage attributed here
The QA fleet's 295 findings were replayed against the merge-base. Most were pre-existing, but these were caused by this stack: Deleting one keyframe destroyed the whole tween. The lane-header remove toggle escalated a flat tween to a whole-animation delete, which took the authored `tl.to(...)` and its source comment with it on a single click. The base build posts remove-keyframe and lets the writer refuse it; restore that. A keyframed layer could not be hidden at all. The visibility eye had moved off the always-mounted layer row onto a hover-gated property-group row, so it only existed while the lanes were expanded AND the pointer was over that lane. A keyboard-only user could reach no eye at all, and its label named a track its row did not act on. It goes back on the layer row. A drag from the centre of a clip bar did nothing, because the 16px inline ease button sits exactly there and swallowed the press. It now lets the press through to the clip and keeps only the click, dropped if the pointer travelled. Dragging a diamond onto a neighbour silently discarded the retime. The clamp bounded the dragged keyframe by the whole merged row, so two animations colliding at one percentage pinned each other in place and the drag resolved back to a click. Clamp against the dragged keyframe's own tween instead. Also: floor the diamond hit box at 12px (the gap-derived size fell to ~7px at the zoom floor), round the diamond tooltip percentage, and prune the keyframe caches when a composition switch drops a file from the scan set — each file only ever cleared its own entries, so the previous composition leaked every element into both keyframeCache and gsapAnimations, with nothing to evict it.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { Fragment, useRef } from "react";
|
||||
import { KEYFRAME_DRAG_THRESHOLD_PX } from "../../components/editor/keyframeDrag";
|
||||
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
||||
|
||||
/** One diamond's geometry within its row, as computed by the lane. */
|
||||
export interface TimelineDiamondMarker {
|
||||
keyframe: TimelineDiamondKeyframe;
|
||||
centerX: number;
|
||||
hitWidth: number;
|
||||
visualSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line between each pair of adjacent diamonds, plus the ease control that
|
||||
* sits at the segment's midpoint. Split out of TimelineClipDiamonds only to keep
|
||||
* that file inside the repo's file-size cap; it has no state of its own beyond
|
||||
* the press-position guard below.
|
||||
*/
|
||||
export function TimelineDiamondConnectors({
|
||||
markers,
|
||||
centerY,
|
||||
baseColor,
|
||||
baseOpacity,
|
||||
groupAware,
|
||||
globalEase,
|
||||
keyframeTarget,
|
||||
onSelectSegment,
|
||||
}: {
|
||||
markers: readonly TimelineDiamondMarker[];
|
||||
centerY: number;
|
||||
baseColor: string;
|
||||
baseOpacity: number;
|
||||
groupAware: boolean;
|
||||
globalEase: string;
|
||||
keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
|
||||
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
||||
}) {
|
||||
// The ease button sits dead centre of its segment, which on a two-keyframe clip
|
||||
// is the centre of the clip bar — the natural place to grab a clip and drag it.
|
||||
// Swallowing pointerdown there made that grab a no-op. Instead the press falls
|
||||
// through to the clip (so the drag starts normally) and the button keeps only
|
||||
// the click, which we drop if the pointer actually travelled.
|
||||
const pressXRef = useRef<number | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map((marker, i) => {
|
||||
const previous = markers[i - 1];
|
||||
if (!previous) return null;
|
||||
const kf = marker.keyframe;
|
||||
const x1 = previous.centerX;
|
||||
const x2 = marker.centerX;
|
||||
if (x2 - x1 < 1) return null;
|
||||
const connectorLeft = x1 + previous.visualSize / 2;
|
||||
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
|
||||
// The ease button targets one segment, so it needs the keyframe's own
|
||||
// animationId/tweenPercentage. On a merged inline row the button is
|
||||
// hidden where the segment is ambiguous (two source animations collide
|
||||
// at this % with different eases; see easeAmbiguous) or the keyframe has
|
||||
// no source animation id (runtime-scanned) so there is no tween to target.
|
||||
const target = keyframeTarget(kf);
|
||||
const ease = kf.ease ?? globalEase;
|
||||
return (
|
||||
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
||||
<div
|
||||
className="absolute"
|
||||
data-keyframe-connector={groupAware ? "" : undefined}
|
||||
style={{
|
||||
left: connectorLeft,
|
||||
top: centerY,
|
||||
width: Math.max(0, connectorWidth),
|
||||
height: 2,
|
||||
transform: "translateY(-1px)",
|
||||
background: baseColor,
|
||||
opacity: baseOpacity,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
{onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
|
||||
<div
|
||||
className="group absolute"
|
||||
data-keyframe-ease-segment=""
|
||||
style={{
|
||||
left: x1,
|
||||
top: centerY,
|
||||
width: x2 - x1,
|
||||
height: 18,
|
||||
transform: "translateY(-50%)",
|
||||
// Own a stacking context above the diamond buttons. At fit
|
||||
// zoom the 16px ease control can overlap its neighbouring
|
||||
// diamond; without a z-index here the later diamond wins the
|
||||
// hit test even though the child button has z-index 3.
|
||||
zIndex: 3,
|
||||
// Only the centered control is interactive. The transparent
|
||||
// segment wrapper must not swallow connector/clip gestures.
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-ease-button=""
|
||||
aria-label={`Edit ${ease} easing`}
|
||||
title={`Edit ${ease} easing`}
|
||||
className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: 16,
|
||||
height: 16,
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 3,
|
||||
pointerEvents: "auto",
|
||||
padding: 0,
|
||||
border: "1px solid rgba(255, 255, 255, 0.14)",
|
||||
background: "#171717",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
pressXRef.current = e.clientX;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const pressX = pressXRef.current;
|
||||
pressXRef.current = null;
|
||||
if (
|
||||
pressX !== null &&
|
||||
Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onSelectSegment(target);
|
||||
}}
|
||||
>
|
||||
<MiniCurveSvg ease={ease} active size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user