mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(studio): double-cast test fixtures and split three dense functions
CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare `as T`; the gsapShared fixtures only carry the fields under test. The fallow complexity gate flagged three functions on this branch. Each is split at its natural seam rather than suppressed: the auto-expand scan moves out of the effect, the four repeated attribute guards in nodeMatchesManifestClip collapse into one table-driven check, and the segment-% interpolation moves out of onPathDown.
This commit is contained in:
@@ -45,6 +45,19 @@ type DragState = {
|
||||
ref: MotionNodeRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tween-% for a stop inserted at fraction `t` along the segment between two
|
||||
* nodes. null when either end is not a keyframe (arc waypoints carry no %).
|
||||
*/
|
||||
function interpolatedKeyframePct(
|
||||
a: MotionNodeRef | undefined,
|
||||
b: MotionNodeRef | undefined,
|
||||
t: number,
|
||||
): number | null {
|
||||
if (a?.type !== "keyframe" || b?.type !== "keyframe") return null;
|
||||
return Math.round((a.pct + (b.pct - a.pct) * t) * 1000) / 1000;
|
||||
}
|
||||
|
||||
const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
|
||||
// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
|
||||
// (select the keyframe); at or above it the gesture commits a move. Screen-space
|
||||
@@ -402,13 +415,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
|
||||
} else {
|
||||
// Linear keyframe path: interpolate the new stop's tween-% from the two
|
||||
// keyframes bounding the clicked segment (np.t = fraction along it), then
|
||||
// insert it. Lands ON the current line, so the dot doesn't jump — drag it
|
||||
// after to bend the path.
|
||||
const a = abs[np.segIndex]?.ref;
|
||||
const b = abs[np.segIndex + 1]?.ref;
|
||||
if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
|
||||
const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
|
||||
// keyframes bounding the clicked segment, then insert it. Lands ON the
|
||||
// current line, so the dot doesn't jump — drag it after to bend the path.
|
||||
const pct = interpolatedKeyframePct(abs[np.segIndex]?.ref, abs[np.segIndex + 1]?.ref, np.t);
|
||||
if (pct === null) return;
|
||||
e.stopPropagation();
|
||||
void commitAddKeyframe(animId, pct, x, y, commitMutation);
|
||||
}
|
||||
|
||||
@@ -11,17 +11,19 @@ import {
|
||||
toClipPercentage,
|
||||
} from "./gsapShared";
|
||||
|
||||
// Fixtures carry only the fields the function under test reads; the double-cast
|
||||
// is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).
|
||||
const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation;
|
||||
|
||||
describe("resolveEditableTweenDuration", () => {
|
||||
const selection = { dataAttributes: { duration: "16.26" } } as DomEditSelection;
|
||||
const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection;
|
||||
|
||||
it("uses the owning clip duration when the tween omits an outer duration", () => {
|
||||
expect(resolveEditableTweenDuration({ duration: undefined } as GsapAnimation, selection)).toBe(
|
||||
16.26,
|
||||
);
|
||||
expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26);
|
||||
});
|
||||
|
||||
it("keeps an explicitly-authored tween duration", () => {
|
||||
expect(resolveEditableTweenDuration({ duration: 4 } as GsapAnimation, selection)).toBe(4);
|
||||
expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,24 @@ import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
* — tracked per-clip so a later user collapse sticks and never bounces back open
|
||||
* (and clips added later still auto-expand).
|
||||
*/
|
||||
/**
|
||||
* Prunes clips that left the source, then returns the ones that newly contribute
|
||||
* a lane. The prune matters because the set is otherwise append-only: a clip
|
||||
* deleted and reinserted under the same id (undo, paste) would be remembered as
|
||||
* already-expanded and never auto-expand again.
|
||||
*/
|
||||
function freshLaneClips(gsapAnimations: Map<string, GsapAnimation[]>, clips: Set<string>) {
|
||||
for (const key of clips) {
|
||||
if (!gsapAnimations.has(key)) clips.delete(key);
|
||||
}
|
||||
const fresh: string[] = [];
|
||||
for (const [key, animations] of gsapAnimations) {
|
||||
if (clips.has(key)) continue;
|
||||
if (animations.some(animationContributesLane)) fresh.push(key);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
export function useAutoExpandKeyframedClips(gsapAnimations: Map<string, GsapAnimation[]>): void {
|
||||
const expandClips = usePlayerStore((s) => s.expandClips);
|
||||
const projectId = useStudioShellContextOptional()?.projectId ?? null;
|
||||
@@ -24,17 +42,7 @@ export function useAutoExpandKeyframedClips(gsapAnimations: Map<string, GsapAnim
|
||||
} else {
|
||||
seen.current.source = gsapAnimations;
|
||||
}
|
||||
// Drop clips that are no longer in the source at all. Without this the set
|
||||
// is append-only, so a clip deleted and reinserted under the same id (undo,
|
||||
// paste) is remembered as already-expanded and never auto-expands again.
|
||||
for (const key of seen.current.clips) {
|
||||
if (!gsapAnimations.has(key)) seen.current.clips.delete(key);
|
||||
}
|
||||
const fresh: string[] = [];
|
||||
for (const [key, animations] of gsapAnimations) {
|
||||
if (seen.current.clips.has(key)) continue;
|
||||
if (animations.some(animationContributesLane)) fresh.push(key);
|
||||
}
|
||||
const fresh = freshLaneClips(gsapAnimations, seen.current.clips);
|
||||
if (fresh.length === 0) return;
|
||||
for (const key of fresh) seen.current.clips.add(key);
|
||||
expandClips(fresh);
|
||||
|
||||
@@ -380,20 +380,21 @@ function numbersNearlyEqual(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < 0.001;
|
||||
}
|
||||
|
||||
const MANIFEST_CLIP_ATTRS: ReadonlyArray<[string, (clip: ClipManifestClip) => number]> = [
|
||||
["data-start", (clip) => clip.start],
|
||||
["data-duration", (clip) => clip.duration],
|
||||
["data-track-index", (clip) => clip.track],
|
||||
];
|
||||
|
||||
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
|
||||
const tagName = clip.tagName?.toLowerCase();
|
||||
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
|
||||
|
||||
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
||||
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
|
||||
|
||||
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
|
||||
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
|
||||
|
||||
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
|
||||
if (Number.isFinite(track) && track !== clip.track) return false;
|
||||
|
||||
return true;
|
||||
// An attribute only constrains the match when it parses to a finite number:
|
||||
// missing or garbled reads as "unknown", not "mismatch".
|
||||
return MANIFEST_CLIP_ATTRS.every(([attr, expected]) => {
|
||||
const actual = Number.parseFloat(node.getAttribute(attr) ?? "");
|
||||
return !Number.isFinite(actual) || numbersNearlyEqual(actual, expected(clip));
|
||||
});
|
||||
}
|
||||
|
||||
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
||||
|
||||
Reference in New Issue
Block a user