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:
@@ -14,6 +14,7 @@ export type {
|
|||||||
GsapMethod,
|
GsapMethod,
|
||||||
GsapKeyframesData,
|
GsapKeyframesData,
|
||||||
GsapPercentageKeyframe,
|
GsapPercentageKeyframe,
|
||||||
|
SourcedGsapPercentageKeyframe,
|
||||||
ParsedGsap,
|
ParsedGsap,
|
||||||
ArcPathConfig,
|
ArcPathConfig,
|
||||||
ArcPathSegment,
|
ArcPathSegment,
|
||||||
|
|||||||
@@ -94,6 +94,20 @@ export interface WritableGsapPercentageKeyframe extends GsapPercentageKeyframe {
|
|||||||
auto?: boolean;
|
auto?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A keyframe that still knows which tween emitted it, and where inside that
|
||||||
|
* tween it sat. Merging several tweens onto one timeline row drops that
|
||||||
|
* provenance unless it rides along on the keyframe, and an editor needs it to
|
||||||
|
* route an edit back to the animation the user actually clicked. Required, not
|
||||||
|
* optional: a keyframe that reaches a merge without it cannot be attributed at
|
||||||
|
* all, and silently treating that as "no collision" is how an edit lands on the
|
||||||
|
* wrong tween.
|
||||||
|
*/
|
||||||
|
export interface SourcedGsapPercentageKeyframe extends GsapPercentageKeyframe {
|
||||||
|
animationId: string;
|
||||||
|
tweenPercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapse duplicate percentage entries before serializing an object literal.
|
* Collapse duplicate percentage entries before serializing an object literal.
|
||||||
* Matches addKeyframeToScript's merge contract: later properties/ease win while
|
* Matches addKeyframeToScript's merge contract: later properties/ease win while
|
||||||
@@ -122,9 +136,9 @@ export function mergePercentageKeyframes(
|
|||||||
|
|
||||||
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
|
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
|
||||||
|
|
||||||
export interface GsapKeyframesData {
|
export interface GsapKeyframesData<K extends GsapPercentageKeyframe = GsapPercentageKeyframe> {
|
||||||
format: GsapKeyframeFormat;
|
format: GsapKeyframeFormat;
|
||||||
keyframes: GsapPercentageKeyframe[];
|
keyframes: K[];
|
||||||
ease?: string;
|
ease?: string;
|
||||||
easeEach?: string;
|
easeEach?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,44 @@ describe("pruneKeyframeCacheToFiles", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("updateKeyframeCacheFromParsed", () => {
|
describe("updateKeyframeCacheFromParsed", () => {
|
||||||
|
it("records colliding animation targets with their own tween percentages", () => {
|
||||||
|
const animation = (
|
||||||
|
id: string,
|
||||||
|
propertyGroup: string,
|
||||||
|
properties: Record<string, number>,
|
||||||
|
percentage: number,
|
||||||
|
resolvedStart: number,
|
||||||
|
): GsapAnimation => ({
|
||||||
|
...animWithKeyframes(id),
|
||||||
|
targetSelector: "#hero",
|
||||||
|
propertyGroup,
|
||||||
|
resolvedStart,
|
||||||
|
keyframes: { format: "percentage", keyframes: [{ percentage, properties }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
usePlayerStore.setState({
|
||||||
|
elements: [{ id: "hero", domId: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
updateKeyframeCacheFromParsed(
|
||||||
|
[
|
||||||
|
animation("hero-position", "position", { x: 100 }, 50, 0.5),
|
||||||
|
animation("hero-visual", "visual", { opacity: 1 }, 80, 0.2),
|
||||||
|
animation("hero-position", "position", { y: 50 }, 25, 0.75),
|
||||||
|
animation("hero-scale", "scale", { scale: 2 }, 60, 0.4),
|
||||||
|
],
|
||||||
|
"scene.html",
|
||||||
|
"hero",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cache().get("scene.html#hero")?.keyframes[0]?.collidingAnimationTargets).toEqual([
|
||||||
|
{ animationId: "hero-position", tweenPercentage: 50 },
|
||||||
|
{ animationId: "hero-visual", tweenPercentage: 80 },
|
||||||
|
{ animationId: "hero-scale", tweenPercentage: 60 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
|
it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
|
||||||
const animation: GsapAnimation = {
|
const animation: GsapAnimation = {
|
||||||
...animWithKeyframes("hero"),
|
...animWithKeyframes("hero"),
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||||
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||||
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
||||||
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
import {
|
||||||
|
deduplicateKeyframes,
|
||||||
|
synthesizeFlatTweenKeyframes,
|
||||||
|
type MergeableKeyframe,
|
||||||
|
} from "./gsapTweenSynth";
|
||||||
|
|
||||||
export function updateKeyframeCacheFromParsed(
|
export function updateKeyframeCacheFromParsed(
|
||||||
animations: GsapAnimation[],
|
animations: GsapAnimation[],
|
||||||
@@ -16,7 +20,11 @@ export function updateKeyframeCacheFromParsed(
|
|||||||
): void {
|
): void {
|
||||||
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
|
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
|
||||||
const idsWithKeyframes = new Set<string>();
|
const idsWithKeyframes = new Set<string>();
|
||||||
const merged = new Map<string, KeyframeCacheEntry>();
|
// Attributed keyframes only: everything in here came from a parsed tween via
|
||||||
|
// toClipKeyframes, so the merge can rely on the source identity. It widens
|
||||||
|
// back into KeyframeCacheEntry on the way to the store, which also holds the
|
||||||
|
// runtime scan's unattributed keyframes.
|
||||||
|
const merged = new Map<string, KeyframeCacheEntry & { keyframes: MergeableKeyframe[] }>();
|
||||||
const sourceAnimations = new Map<string, GsapAnimation[]>();
|
const sourceAnimations = new Map<string, GsapAnimation[]>();
|
||||||
for (const anim of animations) {
|
for (const anim of animations) {
|
||||||
const kfSource =
|
const kfSource =
|
||||||
@@ -49,9 +57,9 @@ export function updateKeyframeCacheFromParsed(
|
|||||||
|
|
||||||
const existing = merged.get(id);
|
const existing = merged.get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
|
// deduplicateKeyframes owns the same-% merge (including the colliding
|
||||||
// flag downstream lanes read); a second copy of that rule here is how the
|
// animation targets downstream lanes read); a second copy of that rule
|
||||||
// two writers drift.
|
// here is how the two writers drift.
|
||||||
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
||||||
} else {
|
} else {
|
||||||
merged.set(id, {
|
merged.set(id, {
|
||||||
|
|||||||
@@ -54,31 +54,86 @@ describe("synthesizeFlatTweenKeyframes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deduplicateKeyframes ease ambiguity", () => {
|
describe("deduplicateKeyframes colliding animation targets", () => {
|
||||||
it("flags a same-% collision from different animations (different eases)", () => {
|
it("records each animation's tween percentage in first-seen order", () => {
|
||||||
const merged = deduplicateKeyframes([
|
const merged = deduplicateKeyframes([
|
||||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
{
|
||||||
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" },
|
percentage: 45,
|
||||||
|
tweenPercentage: 20,
|
||||||
|
properties: { x: 10 },
|
||||||
|
ease: "power2.in",
|
||||||
|
animationId: "#a-position",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 45,
|
||||||
|
tweenPercentage: 80,
|
||||||
|
properties: { opacity: 1 },
|
||||||
|
ease: "power2.out",
|
||||||
|
animationId: "#a-visual",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
const kf = merged.find((k) => k.percentage === 45);
|
const kf = merged.find((k) => k.percentage === 45);
|
||||||
expect(kf?.easeAmbiguous).toBe(true);
|
expect(kf?.collidingAnimationTargets).toEqual([
|
||||||
|
{ animationId: "#a-position", tweenPercentage: 20 },
|
||||||
|
{ animationId: "#a-visual", tweenPercentage: 80 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags a cross-animation collision even when the raw eases match", () => {
|
it("deduplicates three colliding animations while preserving first-seen order", () => {
|
||||||
// The button can still only target one arbitrary animation, and each may
|
|
||||||
// inherit a different easeEach/animation ease that raw comparison misses.
|
|
||||||
const merged = deduplicateKeyframes([
|
const merged = deduplicateKeyframes([
|
||||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
{
|
||||||
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" },
|
percentage: 45,
|
||||||
|
tweenPercentage: 20,
|
||||||
|
properties: { x: 10 },
|
||||||
|
ease: "power2.in",
|
||||||
|
animationId: "#a-position",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 45,
|
||||||
|
tweenPercentage: 80,
|
||||||
|
properties: { opacity: 1 },
|
||||||
|
ease: "power2.in",
|
||||||
|
animationId: "#a-visual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 45,
|
||||||
|
tweenPercentage: 40,
|
||||||
|
properties: { y: 20 },
|
||||||
|
ease: "power2.out",
|
||||||
|
animationId: "#a-position",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 45,
|
||||||
|
tweenPercentage: 60,
|
||||||
|
properties: { scale: 2 },
|
||||||
|
ease: "power2.in",
|
||||||
|
animationId: "#a-scale",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toEqual([
|
||||||
|
{ animationId: "#a-position", tweenPercentage: 20 },
|
||||||
|
{ animationId: "#a-visual", tweenPercentage: 80 },
|
||||||
|
{ animationId: "#a-scale", tweenPercentage: 60 },
|
||||||
]);
|
]);
|
||||||
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not flag a same-% collision within a single animation", () => {
|
it("leaves the collision set undefined within a single animation", () => {
|
||||||
const merged = deduplicateKeyframes([
|
const merged = deduplicateKeyframes([
|
||||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
{
|
||||||
{ percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" },
|
percentage: 45,
|
||||||
|
tweenPercentage: 20,
|
||||||
|
properties: { x: 10 },
|
||||||
|
ease: "power2.in",
|
||||||
|
animationId: "#a-position",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
percentage: 45,
|
||||||
|
tweenPercentage: 80,
|
||||||
|
properties: { y: 20 },
|
||||||
|
ease: "power2.out",
|
||||||
|
animationId: "#a-position",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy();
|
expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
GsapAnimation,
|
GsapAnimation,
|
||||||
GsapKeyframesData,
|
GsapKeyframesData,
|
||||||
GsapPercentageKeyframe,
|
SourcedGsapPercentageKeyframe,
|
||||||
} from "@hyperframes/core/gsap-parser";
|
} from "@hyperframes/core/gsap-parser";
|
||||||
import { PROPERTY_DEFAULTS } from "./gsapShared";
|
import { PROPERTY_DEFAULTS } from "./gsapShared";
|
||||||
|
|
||||||
@@ -22,33 +22,60 @@ export function isStaticPositionHold(anim: GsapAnimation): boolean {
|
|||||||
return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
|
return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deduplicateKeyframes<
|
export interface AnimationKeyframeTarget {
|
||||||
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
|
animationId: string;
|
||||||
>(keyframes: T[]): T[] {
|
tweenPercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accumulateCollidingAnimationTargets(
|
||||||
|
keyframe: AnimationKeyframeTarget & {
|
||||||
|
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||||
|
},
|
||||||
|
incoming: AnimationKeyframeTarget,
|
||||||
|
): void {
|
||||||
|
const primaryId = keyframe.animationId;
|
||||||
|
// One tween meeting itself is not a collision. Both identity fields are
|
||||||
|
// required by the parameter types rather than guarded at runtime: a keyframe
|
||||||
|
// that arrives without them cannot be attributed to a tween at all, and an
|
||||||
|
// early return here would silently record no collision and let the inline
|
||||||
|
// ease button edit an arbitrary one of the tweens that met at this
|
||||||
|
// percentage. The compiler now refuses the incomplete keyframe instead.
|
||||||
|
if (primaryId === incoming.animationId) return;
|
||||||
|
const collisionTargets = keyframe.collidingAnimationTargets;
|
||||||
|
if (collisionTargets?.some((target) => target.animationId === incoming.animationId)) return;
|
||||||
|
keyframe.collidingAnimationTargets = [
|
||||||
|
...(collisionTargets === undefined || collisionTargets.length === 0
|
||||||
|
? [{ animationId: primaryId, tweenPercentage: keyframe.tweenPercentage }]
|
||||||
|
: collisionTargets),
|
||||||
|
{ animationId: incoming.animationId, tweenPercentage: incoming.tweenPercentage },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a keyframe looks like once it has been attributed to its source tween
|
||||||
|
* and is ready to be merged with the other tweens landing on the same row. The
|
||||||
|
* runtime scan produces unattributed keyframes and they never reach a merge, so
|
||||||
|
* they are deliberately not this type.
|
||||||
|
*/
|
||||||
|
export type MergeableKeyframe = SourcedGsapPercentageKeyframe & {
|
||||||
|
propertyGroup?: string;
|
||||||
|
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deduplicateKeyframes<T extends MergeableKeyframe>(keyframes: T[]): T[] {
|
||||||
const byPct = new Map<number, T>();
|
const byPct = new Map<number, T>();
|
||||||
for (const kf of keyframes) {
|
for (const kf of keyframes) {
|
||||||
const existing = byPct.get(kf.percentage);
|
const existing = byPct.get(kf.percentage);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.properties = { ...existing.properties, ...kf.properties };
|
existing.properties = { ...existing.properties, ...kf.properties };
|
||||||
// Two DIFFERENT source animations with a keyframe at the same clip %: a
|
accumulateCollidingAnimationTargets(existing, kf);
|
||||||
// single inline ease button can only target one of them, and which one is
|
// Whichever tween iterated last used to win `ease`, so the merged keyframe
|
||||||
// arbitrary (each may also inherit a different easeEach/animation ease, so
|
// carried an arbitrary one of the colliding curves. Readers that show a
|
||||||
// comparing raw keyframe eases isn't enough). Flag it so the collapsed row
|
// single curve (drag readouts, lane hints, the inline ease button) then
|
||||||
// hides the button there and the user edits per-lane instead.
|
// displayed one belonging to a different animation than the one an edit
|
||||||
if (
|
// targets. A collision means "no single ease", and dropping it is the only
|
||||||
existing.animationId !== undefined &&
|
// honest answer; collidingAnimationTargets still names every tween there.
|
||||||
kf.animationId !== undefined &&
|
if ((existing.collidingAnimationTargets?.length ?? 0) > 1) delete existing.ease;
|
||||||
existing.animationId !== kf.animationId
|
|
||||||
) {
|
|
||||||
existing.easeAmbiguous = true;
|
|
||||||
}
|
|
||||||
// Whichever tween iterated last used to win `ease`, so the merged
|
|
||||||
// keyframe carried an arbitrary one of the colliding curves. Readers that
|
|
||||||
// do not check easeAmbiguous (drag readouts, lane hints) then showed a
|
|
||||||
// curve belonging to a different animation than the one an edit targets.
|
|
||||||
// Drop it instead: ambiguous means "no single ease", and the flag is the
|
|
||||||
// only honest answer.
|
|
||||||
if (existing.easeAmbiguous) delete existing.ease;
|
|
||||||
else if (kf.ease) existing.ease = kf.ease;
|
else if (kf.ease) existing.ease = kf.ease;
|
||||||
} else {
|
} else {
|
||||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
deduplicateKeyframes,
|
deduplicateKeyframes,
|
||||||
isStaticPositionHold,
|
isStaticPositionHold,
|
||||||
synthesizeFlatTweenKeyframes,
|
synthesizeFlatTweenKeyframes,
|
||||||
|
type MergeableKeyframe,
|
||||||
} from "./gsapTweenSynth";
|
} from "./gsapTweenSynth";
|
||||||
|
|
||||||
export { resolveSelectorElementIds };
|
export { resolveSelectorElementIds };
|
||||||
@@ -83,7 +84,7 @@ export async function populateKeyframeCacheFromAst(
|
|||||||
const { setKeyframeCache } = usePlayerStore.getState();
|
const { setKeyframeCache } = usePlayerStore.getState();
|
||||||
clearKeyframeCacheForFile(sf);
|
clearKeyframeCacheForFile(sf);
|
||||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
|
||||||
const sourceByElement = new Map<string, GsapAnimation[]>();
|
const sourceByElement = new Map<string, GsapAnimation[]>();
|
||||||
for (const anim of parsed.animations) {
|
for (const anim of parsed.animations) {
|
||||||
if (anim.hasUnresolvedKeyframes) continue;
|
if (anim.hasUnresolvedKeyframes) continue;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
deduplicateKeyframes,
|
deduplicateKeyframes,
|
||||||
isStaticPositionHold,
|
isStaticPositionHold,
|
||||||
synthesizeFlatTweenKeyframes,
|
synthesizeFlatTweenKeyframes,
|
||||||
|
type MergeableKeyframe,
|
||||||
} from "./gsapTweenSynth";
|
} from "./gsapTweenSynth";
|
||||||
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
||||||
|
|
||||||
@@ -266,13 +267,7 @@ export function useGsapAnimationsForElement(
|
|||||||
domClipChildren,
|
domClipChildren,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allKeyframes: Array<
|
const allKeyframes: MergeableKeyframe[] = [];
|
||||||
GsapKeyframesData["keyframes"][0] & {
|
|
||||||
tweenPercentage?: number;
|
|
||||||
propertyGroup?: string;
|
|
||||||
animationId?: string;
|
|
||||||
}
|
|
||||||
> = [];
|
|
||||||
let format: GsapKeyframesData["format"] = "percentage";
|
let format: GsapKeyframesData["format"] = "percentage";
|
||||||
let ease: string | undefined;
|
let ease: string | undefined;
|
||||||
let easeEach: string | undefined;
|
let easeEach: string | undefined;
|
||||||
|
|||||||
@@ -658,7 +658,18 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
<TimelineDiamondLane
|
<TimelineDiamondLane
|
||||||
keyframesData={{
|
keyframesData={{
|
||||||
format: "percentage",
|
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}
|
clipWidthPx={clipWidthPx}
|
||||||
clipHeightPx={48}
|
clipHeightPx={48}
|
||||||
@@ -675,15 +686,16 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
return { host, root };
|
return { host, root };
|
||||||
};
|
};
|
||||||
|
|
||||||
it("hides the inline ease button on an ambiguous merged segment", () => {
|
it("hides the inline ease button on a colliding merged segment", () => {
|
||||||
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
|
// The 50->100 segment ends on a keyframe shared by two animations, so one
|
||||||
// keyframe, so its hover/ease-button area is not rendered.
|
// 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);
|
const { host, root } = renderSegmentLane(true);
|
||||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
||||||
act(() => root.unmount());
|
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);
|
const { host, root } = renderSegmentLane(false);
|
||||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ export function TimelineDiamondConnectors({
|
|||||||
keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
|
keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
|
||||||
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{markers.map((marker, i) => {
|
{markers.map((marker, i) => {
|
||||||
@@ -55,19 +48,6 @@ export function TimelineDiamondConnectors({
|
|||||||
if (x2 - x1 < 1) return null;
|
if (x2 - x1 < 1) return null;
|
||||||
const connectorLeft = x1 + previous.visualSize / 2;
|
const connectorLeft = x1 + previous.visualSize / 2;
|
||||||
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.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 (
|
return (
|
||||||
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
||||||
<div
|
<div
|
||||||
@@ -84,23 +64,87 @@ export function TimelineDiamondConnectors({
|
|||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
<div
|
||||||
className="group absolute"
|
className="group absolute"
|
||||||
data-keyframe-ease-segment=""
|
data-keyframe-ease-segment=""
|
||||||
style={{
|
style={{
|
||||||
left: x1,
|
left,
|
||||||
top: centerY,
|
top: centerY,
|
||||||
width: x2 - x1,
|
width,
|
||||||
height: 18,
|
height: 18,
|
||||||
transform: "translateY(-50%)",
|
transform: "translateY(-50%)",
|
||||||
// Own a stacking context above the diamond buttons. At fit
|
// Own a stacking context above the diamond buttons. At fit zoom the 16px
|
||||||
// zoom the 16px ease control can overlap its neighbouring
|
// ease control can overlap its neighbouring diamond; without a z-index
|
||||||
// diamond; without a z-index here the later diamond wins the
|
// here the later diamond wins the hit test even though the child button
|
||||||
// hit test even though the child button has z-index 3.
|
// has z-index 3.
|
||||||
zIndex: 3,
|
zIndex: 3,
|
||||||
// Only the centered control is interactive. The transparent
|
// Only the centered control is interactive. The transparent segment
|
||||||
// segment wrapper must not swallow connector/clip gestures.
|
// wrapper must not swallow connector/clip gestures.
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -109,13 +153,12 @@ export function TimelineDiamondConnectors({
|
|||||||
data-keyframe-ease-button=""
|
data-keyframe-ease-button=""
|
||||||
aria-label={`Edit ${ease} easing`}
|
aria-label={`Edit ${ease} easing`}
|
||||||
title={`Edit ${ease} easing`}
|
title={`Edit ${ease} easing`}
|
||||||
// A visible 24x24 badge would collide with the diamonds either
|
// A visible 24x24 badge would collide with the diamonds either side, so
|
||||||
// side, so the WCAG 2.2 (2.5.8) target is met with a centered
|
// the WCAG 2.2 (2.5.8) target is met with a centered transparent
|
||||||
// transparent ::before overlay; the box stays 16x16. Where the
|
// ::before overlay; the box stays 16x16. Where the segment is too narrow
|
||||||
// segment is too narrow for that overlay the button keeps its
|
// for that overlay the button keeps its 16x16 hit area, which is WCAG's
|
||||||
// 16x16 hit area, which is WCAG's target-spacing exception:
|
// target-spacing exception: the neighbouring diamonds are themselves the
|
||||||
// the neighbouring diamonds are themselves the reason it
|
// reason it cannot grow, and stealing their clicks is the worse failure.
|
||||||
// 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-['']" : ""}`}
|
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={{
|
style={{
|
||||||
left: "50%",
|
left: "50%",
|
||||||
@@ -137,22 +180,12 @@ export function TimelineDiamondConnectors({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const pressX = pressXRef.current;
|
const pressX = pressXRef.current;
|
||||||
pressXRef.current = null;
|
pressXRef.current = null;
|
||||||
if (
|
if (pressX !== null && Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX) return;
|
||||||
pressX !== null &&
|
|
||||||
Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelectSegment(target);
|
onSelectSegment(target);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MiniCurveSvg ease={ease} active size={12} />
|
<MiniCurveSvg ease={ease} active size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* keyframe-identity helper live here; the rendering lives there.
|
* keyframe-identity helper live here; the rendering lives there.
|
||||||
*/
|
*/
|
||||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||||
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||||
|
|
||||||
export interface TimelineDiamondKeyframe {
|
export interface TimelineDiamondKeyframe {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
@@ -13,9 +14,8 @@ export interface TimelineDiamondKeyframe {
|
|||||||
animationId?: string;
|
animationId?: string;
|
||||||
properties: Record<string, number | string>;
|
properties: Record<string, number | string>;
|
||||||
ease?: string;
|
ease?: string;
|
||||||
/** Set when 2+ source animations collide at this percentage (a single inline
|
/** Source animation/keyframe targets that collide at this clip percentage. */
|
||||||
* ease button can't target one): the collapsed row hides the button here. */
|
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||||
easeAmbiguous?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyframeCacheEntry {
|
interface KeyframeCacheEntry {
|
||||||
@@ -116,5 +116,6 @@ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyfr
|
|||||||
tweenPercentage: keyframe.tweenPercentage,
|
tweenPercentage: keyframe.tweenPercentage,
|
||||||
propertyGroup: keyframe.propertyGroup,
|
propertyGroup: keyframe.propertyGroup,
|
||||||
animationId: keyframe.animationId,
|
animationId: keyframe.animationId,
|
||||||
|
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||||
|
|
||||||
export interface TimelineKeyframeTarget {
|
export interface TimelineKeyframeTarget {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
tweenPercentage?: number;
|
tweenPercentage?: number;
|
||||||
propertyGroup?: string;
|
propertyGroup?: string;
|
||||||
animationId?: string;
|
animationId?: string;
|
||||||
|
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,72 +36,92 @@ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
|
|||||||
animationId: "position-tween",
|
animationId: "position-tween",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COLLIDING_TARGET: TimelineKeyframeTarget = {
|
||||||
|
...FLAT_TWEEN_TARGET,
|
||||||
|
collidingAnimationTargets: [
|
||||||
|
{ animationId: "position-tween", tweenPercentage: 100 },
|
||||||
|
{ animationId: "scale-tween", tweenPercentage: 75 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
trackStudioSegmentEaseEdit.mockClear();
|
trackStudioSegmentEaseEdit.mockClear();
|
||||||
usePlayerStore.setState({ focusedEaseSegment: null });
|
usePlayerStore.setState({ focusedEaseSegment: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useTimelineKeyframeHandlers", () => {
|
/**
|
||||||
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
|
* Mount the hook on its own and hand back the handlers it returned, with the
|
||||||
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
|
* 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() {
|
function Harness() {
|
||||||
({ onSelectSegment } = useTimelineKeyframeHandlers({
|
Object.assign(
|
||||||
|
handlers,
|
||||||
|
useTimelineKeyframeHandlers({
|
||||||
expandedElements: [ELEMENT],
|
expandedElements: [ELEMENT],
|
||||||
keyframeCache: new Map(),
|
keyframeCache: new Map(),
|
||||||
setSelectedElementId: vi.fn(),
|
setSelectedElementId: vi.fn(),
|
||||||
setKfContextMenu: vi.fn(),
|
setKfContextMenu: vi.fn(),
|
||||||
toggleSelectedKeyframe: vi.fn(),
|
toggleSelectedKeyframe: vi.fn(),
|
||||||
}));
|
...options,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = mountReactHarness(<Harness />);
|
return { root: mountReactHarness(<Harness />), handlers };
|
||||||
act(() => onSelectSegment?.(ELEMENT.id, TARGET));
|
}
|
||||||
|
|
||||||
|
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).toHaveBeenCalledOnce();
|
||||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
|
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
|
||||||
act(() => root.unmount());
|
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", () => {
|
it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
|
||||||
const onSeek = vi.fn();
|
const onSeek = vi.fn();
|
||||||
const onSelectElement = vi.fn();
|
const onSelectElement = vi.fn();
|
||||||
const setSelectedElementId = vi.fn();
|
const setSelectedElementId = vi.fn();
|
||||||
let onClickKeyframe:
|
const { root, handlers } = mountHandlers({ onSelectElement, onSeek, setSelectedElementId });
|
||||||
| ((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 />);
|
|
||||||
|
|
||||||
// Selecting a segment must NOT move the playhead.
|
// 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(onSeek).not.toHaveBeenCalled();
|
||||||
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
||||||
animationId: "position-tween",
|
animationId: "position-tween",
|
||||||
tweenPercentage: 100,
|
tweenPercentage: 100,
|
||||||
elementId: ELEMENT.id,
|
elementId: ELEMENT.id,
|
||||||
});
|
});
|
||||||
|
expect(usePlayerStore.getState().focusedEaseSegment?.collidingAnimationTargets).toBeUndefined();
|
||||||
expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
|
expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
|
||||||
expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
|
expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
|
||||||
|
|
||||||
// Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
|
// 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);
|
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function useTimelineKeyframeHandlers({
|
|||||||
if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
|
if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
|
||||||
usePlayerStore.getState().setFocusedEaseSegment({
|
usePlayerStore.getState().setFocusedEaseSegment({
|
||||||
animationId: target.animationId,
|
animationId: target.animationId,
|
||||||
|
collidingAnimationTargets: target.collidingAnimationTargets,
|
||||||
tweenPercentage: target.tweenPercentage,
|
tweenPercentage: target.tweenPercentage,
|
||||||
elementId: elId,
|
elementId: elId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||||
import type { StoreApi } from "zustand";
|
import type { StoreApi } from "zustand";
|
||||||
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||||
|
|
||||||
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
||||||
export interface KeyframeCacheEntry {
|
export interface KeyframeCacheEntry {
|
||||||
@@ -14,9 +15,8 @@ export interface KeyframeCacheEntry {
|
|||||||
animationId?: string;
|
animationId?: string;
|
||||||
properties: Record<string, number | string>;
|
properties: Record<string, number | string>;
|
||||||
ease?: string;
|
ease?: string;
|
||||||
/** Set when 2+ source animations collide at this percentage (a single inline
|
/** Source animation/keyframe targets that collide at this clip percentage. */
|
||||||
* ease button can't target one): the collapsed row hides the button here. */
|
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||||
easeAmbiguous?: boolean;
|
|
||||||
}>;
|
}>;
|
||||||
ease?: string;
|
ease?: string;
|
||||||
easeEach?: string;
|
easeEach?: string;
|
||||||
@@ -37,9 +37,19 @@ export interface KeyframeSlice {
|
|||||||
|
|
||||||
/** elementId scopes the request to one element so a shared (class-selector)
|
/** elementId scopes the request to one element so a shared (class-selector)
|
||||||
* animation id can't open the ease editor on the wrong element. */
|
* 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: (
|
setFocusedEaseSegment: (
|
||||||
target: { animationId: string; tweenPercentage: number; elementId: string } | null,
|
target: {
|
||||||
|
animationId: string;
|
||||||
|
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||||
|
tweenPercentage: number;
|
||||||
|
elementId: string;
|
||||||
|
} | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
||||||
|
|||||||
Reference in New Issue
Block a user