mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(studio): target colliding keyframes exactly (#2692)
This commit is contained in:
@@ -658,7 +658,18 @@ describe("TimelineClipDiamonds", () => {
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
|
||||
keyframes: [
|
||||
kf(0),
|
||||
kf(50),
|
||||
kf(100, {
|
||||
collidingAnimationTargets: lastAmbiguous
|
||||
? [
|
||||
{ animationId: "anim-1", tweenPercentage: 100 },
|
||||
{ animationId: "anim-2", tweenPercentage: 75 },
|
||||
]
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
}}
|
||||
clipWidthPx={clipWidthPx}
|
||||
clipHeightPx={48}
|
||||
@@ -675,15 +686,16 @@ describe("TimelineClipDiamonds", () => {
|
||||
return { host, root };
|
||||
};
|
||||
|
||||
it("hides the inline ease button on an ambiguous merged segment", () => {
|
||||
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
|
||||
// keyframe, so its hover/ease-button area is not rendered.
|
||||
it("hides the inline ease button on a colliding merged segment", () => {
|
||||
// The 50->100 segment ends on a keyframe shared by two animations, so one
|
||||
// button cannot honestly stand for the several curves that meet there. Only
|
||||
// the unambiguous 0->50 segment keeps its button.
|
||||
const { host, root } = renderSegmentLane(true);
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the inline ease button on unambiguous merged segments", () => {
|
||||
it("shows the inline ease button on single-animation merged segments", () => {
|
||||
const { host, root } = renderSegmentLane(false);
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
||||
act(() => root.unmount());
|
||||
|
||||
@@ -37,13 +37,6 @@ export function TimelineDiamondConnectors({
|
||||
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) => {
|
||||
@@ -55,19 +48,6 @@ export function TimelineDiamondConnectors({
|
||||
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;
|
||||
// connectorWidth is the clear span between the two diamonds' edges, so a
|
||||
// 24x24 target centred in it overhangs a diamond as soon as the span is
|
||||
// narrower than 24. The segment wrapper sits at z-index 3, above the
|
||||
// diamonds, so that overhang would win the hit test and steal their
|
||||
// clicks at fit zoom. Grow the target only where the room exists.
|
||||
const roomForFullTarget = connectorWidth >= 24;
|
||||
return (
|
||||
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
||||
<div
|
||||
@@ -84,71 +64,22 @@ export function TimelineDiamondConnectors({
|
||||
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`}
|
||||
// A visible 24x24 badge would collide with the diamonds either
|
||||
// side, so the WCAG 2.2 (2.5.8) target is met with a centered
|
||||
// transparent ::before overlay; the box stays 16x16. Where the
|
||||
// segment is too narrow for that overlay the button keeps its
|
||||
// 16x16 hit area, which is WCAG's target-spacing exception:
|
||||
// the neighbouring diamonds are themselves the reason it
|
||||
// cannot grow, and stealing their clicks is the worse failure.
|
||||
className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
||||
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>
|
||||
{onSelectSegment && showsEaseControl(kf) && (
|
||||
<SegmentEaseControl
|
||||
left={x1}
|
||||
width={x2 - x1}
|
||||
centerY={centerY}
|
||||
ease={kf.ease ?? globalEase}
|
||||
target={keyframeTarget(kf)}
|
||||
// connectorWidth is the clear span between the two diamonds'
|
||||
// edges, so a 24x24 target centred in it overhangs a diamond as
|
||||
// soon as the span is narrower than 24. The segment wrapper sits
|
||||
// at z-index 3, above the diamonds, so that overhang would win
|
||||
// the hit test and steal their clicks at fit zoom. Grow the
|
||||
// target only where the room exists.
|
||||
roomForFullTarget={connectorWidth >= 24}
|
||||
onSelectSegment={onSelectSegment}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
@@ -156,3 +87,105 @@ export function TimelineDiamondConnectors({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ease control targets one segment, so it needs the keyframe's own
|
||||
* animationId/tweenPercentage. On a merged inline row it is hidden where two
|
||||
* source animations collide at this percentage (one button cannot honestly
|
||||
* stand for several curves) or the keyframe has no source animation id
|
||||
* (runtime-scanned) so there is no tween to target.
|
||||
*/
|
||||
function showsEaseControl(kf: TimelineDiamondKeyframe): boolean {
|
||||
return (kf.collidingAnimationTargets?.length ?? 0) <= 1 && kf.animationId !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ease button centred on one connector segment, plus the transparent
|
||||
* wrapper that positions it. Split out of the connector map so that map stays a
|
||||
* geometry loop and this keeps the press guard, hit-target sizing and click
|
||||
* filtering together.
|
||||
*/
|
||||
function SegmentEaseControl({
|
||||
left,
|
||||
width,
|
||||
centerY,
|
||||
ease,
|
||||
target,
|
||||
roomForFullTarget,
|
||||
onSelectSegment,
|
||||
}: {
|
||||
left: number;
|
||||
width: number;
|
||||
centerY: number;
|
||||
ease: string;
|
||||
target: TimelineKeyframeTarget;
|
||||
roomForFullTarget: boolean;
|
||||
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 (
|
||||
<div
|
||||
className="group absolute"
|
||||
data-keyframe-ease-segment=""
|
||||
style={{
|
||||
left,
|
||||
top: centerY,
|
||||
width,
|
||||
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`}
|
||||
// A visible 24x24 badge would collide with the diamonds either side, so
|
||||
// the WCAG 2.2 (2.5.8) target is met with a centered transparent
|
||||
// ::before overlay; the box stays 16x16. Where the segment is too narrow
|
||||
// for that overlay the button keeps its 16x16 hit area, which is WCAG's
|
||||
// target-spacing exception: the neighbouring diamonds are themselves the
|
||||
// reason it cannot grow, and stealing their clicks is the worse failure.
|
||||
className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* keyframe-identity helper live here; the rendering lives there.
|
||||
*/
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||
|
||||
export interface TimelineDiamondKeyframe {
|
||||
percentage: number;
|
||||
@@ -13,9 +14,8 @@ export interface TimelineDiamondKeyframe {
|
||||
animationId?: string;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** Set when 2+ source animations collide at this percentage (a single inline
|
||||
* ease button can't target one): the collapsed row hides the button here. */
|
||||
easeAmbiguous?: boolean;
|
||||
/** Source animation/keyframe targets that collide at this clip percentage. */
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
}
|
||||
|
||||
interface KeyframeCacheEntry {
|
||||
@@ -116,5 +116,6 @@ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyfr
|
||||
tweenPercentage: keyframe.tweenPercentage,
|
||||
propertyGroup: keyframe.propertyGroup,
|
||||
animationId: keyframe.animationId,
|
||||
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||
|
||||
export interface TimelineKeyframeTarget {
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,72 +36,92 @@ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
|
||||
animationId: "position-tween",
|
||||
};
|
||||
|
||||
const COLLIDING_TARGET: TimelineKeyframeTarget = {
|
||||
...FLAT_TWEEN_TARGET,
|
||||
collidingAnimationTargets: [
|
||||
{ animationId: "position-tween", tweenPercentage: 100 },
|
||||
{ animationId: "scale-tween", tweenPercentage: 75 },
|
||||
],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
trackStudioSegmentEaseEdit.mockClear();
|
||||
usePlayerStore.setState({ focusedEaseSegment: null });
|
||||
});
|
||||
|
||||
describe("useTimelineKeyframeHandlers", () => {
|
||||
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
|
||||
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
|
||||
/**
|
||||
* Mount the hook on its own and hand back the handlers it returned, with the
|
||||
* options every test shares already filled in. Each test overrides only the
|
||||
* inputs its assertion is about.
|
||||
*/
|
||||
function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHandlers>[0]> = {}) {
|
||||
const handlers: Partial<ReturnType<typeof useTimelineKeyframeHandlers>> = {};
|
||||
|
||||
function Harness() {
|
||||
({ onSelectSegment } = useTimelineKeyframeHandlers({
|
||||
function Harness() {
|
||||
Object.assign(
|
||||
handlers,
|
||||
useTimelineKeyframeHandlers({
|
||||
expandedElements: [ELEMENT],
|
||||
keyframeCache: new Map(),
|
||||
setSelectedElementId: vi.fn(),
|
||||
setKfContextMenu: vi.fn(),
|
||||
toggleSelectedKeyframe: vi.fn(),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = mountReactHarness(<Harness />);
|
||||
act(() => onSelectSegment?.(ELEMENT.id, TARGET));
|
||||
return { root: mountReactHarness(<Harness />), handlers };
|
||||
}
|
||||
|
||||
describe("useTimelineKeyframeHandlers", () => {
|
||||
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
|
||||
const { root, handlers } = mountHandlers();
|
||||
act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
|
||||
|
||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
|
||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("focuses a merged segment with its colliding animation targets", () => {
|
||||
const { root, handlers } = mountHandlers();
|
||||
act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
|
||||
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
||||
animationId: "position-tween",
|
||||
collidingAnimationTargets: [
|
||||
{ animationId: "position-tween", tweenPercentage: 100 },
|
||||
{ animationId: "scale-tween", tweenPercentage: 75 },
|
||||
],
|
||||
tweenPercentage: 100,
|
||||
elementId: ELEMENT.id,
|
||||
});
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
|
||||
const onSeek = vi.fn();
|
||||
const onSelectElement = vi.fn();
|
||||
const setSelectedElementId = vi.fn();
|
||||
let onClickKeyframe:
|
||||
| ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
|
||||
| undefined;
|
||||
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
|
||||
|
||||
function Harness() {
|
||||
({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
|
||||
expandedElements: [ELEMENT],
|
||||
keyframeCache: new Map(),
|
||||
onSelectElement,
|
||||
onSeek,
|
||||
setSelectedElementId,
|
||||
setKfContextMenu: vi.fn(),
|
||||
toggleSelectedKeyframe: vi.fn(),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = mountReactHarness(<Harness />);
|
||||
const { root, handlers } = mountHandlers({ onSelectElement, onSeek, setSelectedElementId });
|
||||
|
||||
// Selecting a segment must NOT move the playhead.
|
||||
act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
|
||||
act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
||||
animationId: "position-tween",
|
||||
tweenPercentage: 100,
|
||||
elementId: ELEMENT.id,
|
||||
});
|
||||
expect(usePlayerStore.getState().focusedEaseSegment?.collidingAnimationTargets).toBeUndefined();
|
||||
expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
|
||||
expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
|
||||
|
||||
// Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
|
||||
act(() => onClickKeyframe?.(ELEMENT, TARGET));
|
||||
act(() => handlers.onClickKeyframe?.(ELEMENT, TARGET));
|
||||
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useTimelineKeyframeHandlers({
|
||||
if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
|
||||
usePlayerStore.getState().setFocusedEaseSegment({
|
||||
animationId: target.animationId,
|
||||
collidingAnimationTargets: target.collidingAnimationTargets,
|
||||
tweenPercentage: target.tweenPercentage,
|
||||
elementId: elId,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||
|
||||
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
||||
export interface KeyframeCacheEntry {
|
||||
@@ -14,9 +15,8 @@ export interface KeyframeCacheEntry {
|
||||
animationId?: string;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** Set when 2+ source animations collide at this percentage (a single inline
|
||||
* ease button can't target one): the collapsed row hides the button here. */
|
||||
easeAmbiguous?: boolean;
|
||||
/** Source animation/keyframe targets that collide at this clip percentage. */
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
}>;
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
@@ -37,9 +37,19 @@ export interface KeyframeSlice {
|
||||
|
||||
/** elementId scopes the request to one element so a shared (class-selector)
|
||||
* animation id can't open the ease editor on the wrong element. */
|
||||
focusedEaseSegment: { animationId: string; tweenPercentage: number; elementId: string } | null;
|
||||
focusedEaseSegment: {
|
||||
animationId: string;
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
tweenPercentage: number;
|
||||
elementId: string;
|
||||
} | null;
|
||||
setFocusedEaseSegment: (
|
||||
target: { animationId: string; tweenPercentage: number; elementId: string } | null,
|
||||
target: {
|
||||
animationId: string;
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
tweenPercentage: number;
|
||||
elementId: string;
|
||||
} | null,
|
||||
) => void;
|
||||
|
||||
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
||||
|
||||
Reference in New Issue
Block a user