fix(studio): static-position drag no longer freezes an element beside an animated rotation (#2016)

## What

Brief description of the change.

## Why

Why is this change needed?

## How

How was this implemented? Any notable design decisions?

## Test plan

How was this tested?

- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
This commit is contained in:
Miguel Ángel
2026-07-07 03:45:17 -04:00
committed by GitHub
parent 306a291dea
commit 5d59835446
8 changed files with 280 additions and 112 deletions
@@ -0,0 +1,109 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
interface UpdatePropertyMutation {
type: "update-property";
animationId: string;
property: string;
value: number;
}
/**
* Build the `instantPatch` for a value-only `tl.set` from the SAME
* `update-property` mutation(s) that are POSTed — so the patch can never carry a
* value the source write didn't (one source of truth). Each mutation contributes
* its `{property: value}` channel to the patch's props.
*/
export function setPatchFromUpdateProperties(
selector: string,
mutations: UpdatePropertyMutation[],
global = false,
): { selector: string; change: RuntimeTweenChange } {
const props: SetPatchProps = {};
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
// An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
// element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
return { selector, change: { kind: global ? "global-set" : "set", props } };
}
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
export function setPatchFromUpdateProperty(
selector: string,
mutation: UpdatePropertyMutation,
global = false,
): { selector: string; change: RuntimeTweenChange } {
return setPatchFromUpdateProperties(selector, [mutation], global);
}
/**
* Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
* with no duration. This is what a static-element nudge writes/updates.
*/
function findPositionSetAnimation(
animations: GsapAnimation[],
selector: string,
): GsapAnimation | null {
return (
animations.find(
(a) =>
a.method === "set" &&
a.targetSelector === selector &&
("x" in a.properties || "y" in a.properties),
) ?? null
);
}
/**
* Find the EXISTING static position HOLD to update for a static-hold drag. Not
* just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
* remove-all-keyframes leaves behind) is a held position too, and the next drag
* must UPDATE it in place rather than append a second `gsap.set` that fights it
* (the duplicate-position-write bug). Only zero-duration holds qualify — a
* live-duration `to`/`from` is NOT a static hold (and in the static path it's a
* stale/phantom parse: re-committing it would resurrect a just-deleted tween).
* A keyframed zero-duration `to` is ALSO a static hold (a drag-path corruption
* artifact) and must be recognized so the static commit normalizes it.
* Prefers a `set` (the canonical static channel) when both forms exist.
*/
export function findExistingPositionWrite(
animations: GsapAnimation[],
selector: string,
): GsapAnimation | null {
const set = findPositionSetAnimation(animations, selector);
if (set) return set;
return (
animations.find(
(a) =>
a.targetSelector === selector &&
a.propertyGroup === "position" &&
resolveTweenDuration(a) === 0,
) ?? null
);
}
export function findRotationSetAnimation(
animations: GsapAnimation[],
selector: string,
): GsapAnimation | null {
return (
animations.find(
(a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
) ?? null
);
}
export function findSizeSetAnimation(
animations: GsapAnimation[],
selector: string,
): GsapAnimation | null {
return (
animations.find(
(a) =>
a.method === "set" &&
a.targetSelector === selector &&
("width" in a.properties || "height" in a.properties),
) ?? null
);
}