Files
hyperframes/packages/studio/src/hooks/gsapDragStaticSetHelpers.ts
T
Miguel Angel Simon Sierra 3d92436066 fix(studio): author every new tween against one element
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.
2026-07-28 21:21:37 +02:00

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
);
}