mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
U3 fixed "add keyframe at playhead" widening a write to every sibling sharing a class, but wired writeTargetSelector into only two paths. The same bug was still reachable from the add-animation button, drag, resize, rotate, gesture recording, and the property panel: each derived its target from selectorFromSelection, which hands back a bare class for an id-less element, so one edit authored a tween over all five siblings and the timeline collapsed their rows into one. Route every path that authors a NEW tween through the existing ladder: - ensureElementAddressable now accepts selection.selector only when it addresses exactly one element, so the id-minting fallback right below it (previously unreachable whenever any selector was present) does the work. - gsapDragCommit's five new-tween branches go through one newTweenTarget helper; instant patches reuse the written target so the runtime moves the element the source write names. - useGestureCommit and useAnimatedPropertyCommit keep the existing selector for matching/retargeting and author new tweens with a separate write selector. Retargets of an EXISTING tween are deliberately untouched: they keep anim.targetSelector, so a tween aimed at a whole group stays aimed at it. Narrowing the write alone regressed idempotency, verified by test: the "is there already a write for this element" lookups matched targetSelector by string, so the next nudge missed the write it had just made and appended a second, conflicting one. The read half now falls back to the live DOM (tweenTargetsElement, same contract as getAnimationsForElement), which also still matches a deliberate group tween. Tests reproduce each site through a real writer, re-parse with the real parser, and resolve through resolveSelectorElementIds (what feeds the keyframe cache and the lanes), plus pins for the new-tween vs retarget-existing distinction so a future change cannot collapse the two.
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
|
|
import { isInstantHold, tweenTargetsElement } from "./gsapShared";
|
|
|
|
/** 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 setPatchFromUpdateProperty(
|
|
selector: string,
|
|
mutation: UpdatePropertyMutation,
|
|
global = false,
|
|
): { selector: string; change: RuntimeTweenChange } {
|
|
const props: SetPatchProps = { [mutation.property as keyof SetPatchProps]: mutation.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 } };
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
element?: Element | null,
|
|
): GsapAnimation | null {
|
|
return (
|
|
animations.find(
|
|
(a) =>
|
|
a.method === "set" &&
|
|
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
("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 tween and a duration-zero `from` are NOT static holds (and in
|
|
* the static path they're 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,
|
|
element?: Element | null,
|
|
): GsapAnimation | null {
|
|
const set = findPositionSetAnimation(animations, selector, element);
|
|
if (set) return set;
|
|
return (
|
|
animations.find(
|
|
(a) =>
|
|
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
a.propertyGroup === "position" &&
|
|
isInstantHold(a),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
export function findRotationSetAnimation(
|
|
animations: GsapAnimation[],
|
|
selector: string,
|
|
element?: Element | null,
|
|
): GsapAnimation | null {
|
|
return (
|
|
animations.find(
|
|
(a) =>
|
|
isInstantHold(a) &&
|
|
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
"rotation" in a.properties,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
export function findSizeSetAnimation(
|
|
animations: GsapAnimation[],
|
|
selector: string,
|
|
element?: Element | null,
|
|
): GsapAnimation | null {
|
|
return (
|
|
animations.find(
|
|
(a) =>
|
|
isInstantHold(a) &&
|
|
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
("width" in a.properties || "height" in a.properties),
|
|
) ?? null
|
|
);
|
|
}
|