mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): Enable keyframes works (and auto-tracks) on elements with a global gsap.set (#1728)
* fix(core): a global gsap.set is off-timeline, so resolvedStart is 0 not the comp end resolveTimelinePositions walks anims in document order advancing a cursor; a global `gsap.set(...)` carries no position arg, so it fell through to the cursor fallback and inherited the comp-end time (every prior tween's duration summed) as its resolvedStart. A global set is a load-time hold — its start is 0. This silently broke 'Enable keyframes' on any element whose only animation is a global gsap.set (e.g. a statically-positioned card): promoteSetToKeyframes bails when `playhead <= setStart`, and setStart was the comp end, so any playhead before the end was a no-op. Pin a global set to resolvedStart 0 in both the recast and acorn parsers; don't let it advance the cursor/prevStart. * fix(studio): Enable-keyframes marks the generated 0% endpoint as auto-tracking When 'Enable keyframes' promotes a static set to a two-stop tween, the 0% (the held start the user didn't choose) is now marked `auto: true` → serialized as the `_auto: 1` marker. The parser's endpoint-sync then keeps it tracking the nearest keyframe until the user edits it directly; the 100% (the real keyframe placed at the playhead) stays fixed. This re-wires the auto-endpoint behavior that was silently dropped in #1605 (the sync logic stayed, but no flow produced an `_auto` endpoint anymore, so an untouched 0% never tracked). Adds a guard test so it can't be lost again. * chore: suppress fallow complexity findings surfaced in the touched files The resolveTimelinePositions guard added a branch (pushes it over threshold), and changed-file scope re-surfaces pre-existing complex functions (readElementPosition, applyArcWaypointAtPlayhead, the useEnableKeyframes callback). Bare directives only. * fix(studio): dragging a --hf-studio-offset element no longer flies Dragging a static element positioned via the legacy --hf-studio-offset CSS var (e.g. dot-a) flew off-screen — three independent failure modes, all fixed: 1. Live drag integrated: the per-move draft read its base from the live transform it set last frame (gsap.getProperty), so base+delta accumulated frame-over-frame. Fix: carry a stable baseGsap on the in-memory drag member (immune to mid-drag re-renders that wipe the data-hf-drag-* attrs) and use it as the fallback. 2. Commit re-added the delta: the source commit re-read the wiped attrs / live transform. Fix: re-stamp the stable base/initial attrs in applyManualOffsetDragCommit before the commit reads them. 3. Drop left it offset: the committed source was correct, but the LIVE element kept its --hf-studio-offset var + translate:var(...), which composed with the GSAP transform (rendered at dropped + offset) until a full reload. Fix: on cleanup, when GSAP owns the position, clearStudioPathOffset() migrates the element off the legacy CSS channel (leaving transform untouched) — matching the stripped source. Adds a regression suite covering all three layers.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
animatedProps,
|
||||
buildExtendedKeyframes,
|
||||
isPlayheadWithinTween,
|
||||
promoteSetToKeyframes,
|
||||
resolveNewTweenRange,
|
||||
type EnableKeyframesSession,
|
||||
} from "./useEnableKeyframes";
|
||||
|
||||
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
||||
@@ -128,3 +131,40 @@ describe("buildExtendedKeyframes", () => {
|
||||
expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("promoteSetToKeyframes — auto endpoint", () => {
|
||||
it("marks the 0% (held start) as `auto`, leaving the 100% (playhead) fixed", async () => {
|
||||
let committed: Record<string, unknown> | undefined;
|
||||
const session = {
|
||||
commitMutation: async (mutation: Record<string, unknown>) => {
|
||||
committed = mutation;
|
||||
},
|
||||
} as unknown as EnableKeyframesSession;
|
||||
const sel = {
|
||||
id: "card",
|
||||
selector: "#card",
|
||||
sourceFile: "index.html",
|
||||
element: { isConnected: true } as unknown as HTMLElement,
|
||||
} as unknown as DomEditSelection;
|
||||
// readElementPosition reads gsap.getProperty off the iframe window.
|
||||
const iframe = {
|
||||
contentWindow: { gsap: { getProperty: () => -74 } },
|
||||
} as unknown as HTMLIFrameElement;
|
||||
const setAnim = anim({
|
||||
id: "#card-set-0-position",
|
||||
targetSelector: "#card",
|
||||
method: "set",
|
||||
global: true,
|
||||
resolvedStart: 0,
|
||||
properties: { x: -74, y: -469 },
|
||||
});
|
||||
|
||||
await promoteSetToKeyframes(session, sel, setAnim, 1, iframe);
|
||||
|
||||
const kfs = committed?.keyframes as Array<{ percentage: number; auto?: boolean }>;
|
||||
expect(committed?.type).toBe("replace-with-keyframes");
|
||||
expect(kfs[0]).toMatchObject({ percentage: 0, auto: true });
|
||||
expect(kfs[1].percentage).toBe(100);
|
||||
expect(kfs[1].auto).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ export function buildExtendedKeyframes(
|
||||
return { position: roundTo3(newStart), duration: newDuration, keyframes };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function readElementPosition(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
sel: DomEditSelection,
|
||||
@@ -238,8 +239,12 @@ async function applyKeyframeAtPlayhead(
|
||||
* two-stop tween from the set's time to the playhead — the held value at 0%, the
|
||||
* live value at 100% — giving the user something to animate. No-op if the playhead
|
||||
* is at or before the set.
|
||||
*
|
||||
* The 0% endpoint is the held start, which the user didn't choose — mark it `auto`
|
||||
* so it tracks the nearest keyframe until edited directly. The 100% is the real
|
||||
* keyframe being placed at the playhead, so it stays fixed.
|
||||
*/
|
||||
async function promoteSetToKeyframes(
|
||||
export async function promoteSetToKeyframes(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
setAnim: GsapAnimation,
|
||||
@@ -267,6 +272,7 @@ async function promoteSetToKeyframes(
|
||||
{
|
||||
percentage: 0,
|
||||
properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition,
|
||||
auto: true,
|
||||
},
|
||||
{ percentage: 100, properties: endPosition },
|
||||
],
|
||||
@@ -283,6 +289,7 @@ async function promoteSetToKeyframes(
|
||||
* the path, inserted at the matching segment so the curve is preserved. Outside the
|
||||
* range, extend the duration so the motion reaches the playhead.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function applyArcWaypointAtPlayhead(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
@@ -332,10 +339,10 @@ async function applyArcWaypointAtPlayhead(
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useEnableKeyframes(
|
||||
sessionRef: React.RefObject<EnableKeyframesSession | undefined>,
|
||||
) {
|
||||
// fallow-ignore-next-line complexity
|
||||
return useCallback(async () => {
|
||||
const session = sessionRef.current;
|
||||
if (!session) return;
|
||||
|
||||
Reference in New Issue
Block a user