Merge pull request #2849 from heygen-com/fix/studio-new-tween-target

fix(studio): author every new tween against one element
This commit is contained in:
Miguel Ángel
2026-07-28 21:57:52 +02:00
committed by GitHub
15 changed files with 917 additions and 50 deletions
@@ -132,7 +132,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
const createSelector = createMode ? selectorFor(selection) : null;
const compW = compositionSize?.width ?? null;
const canCreate = createMode && hasMotionPathPlugin(iframeRef.current);
// No one-element selector means the path could only be authored onto the
// element's class siblings, so the toolbar toggle stays hidden instead of
// arming a press that the effect below would silently drop.
const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
// Publish whether the selected element can take a path so the preview toolbar
// shows its "set destination" toggle. Drops to false when this overlay unmounts
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import type { DomEditSelection } from "./domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
import { selectorFor } from "./motionPathSelection";
afterEach(() => {
document.body.innerHTML = "";
});
function selectionFor(el: HTMLElement): DomEditSelection {
const selector = buildStableSelector(el);
return {
element: el,
id: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}
function mountGroupSiblings(): HTMLElement[] {
document.body.innerHTML = `
<div id="scene" class="clip" data-start="0" data-duration="2">
<div class="group"></div>
<div class="group"></div>
<div class="group"></div>
</div>
`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}
describe("selectorFor", () => {
it("addresses one element for a class-only sibling", () => {
const groups = mountGroupSiblings();
const selector = selectorFor(selectionFor(groups[2]!));
// The bare ".group" both measured home off the FIRST sibling and wrote the
// new motion path onto all three.
expect(selector).not.toBe(".group");
expect(document.querySelectorAll(selector!)).toHaveLength(1);
expect(document.querySelector(selector!)).toBe(groups[2]);
});
it("keeps a unique id target", () => {
document.body.innerHTML = `<div id="hero"></div>`;
const el = document.querySelector<HTMLElement>("#hero")!;
expect(selectorFor(selectionFor(el))).toBe("#hero");
});
it("returns null with no selection", () => {
expect(selectorFor(null)).toBeNull();
});
it("returns null when no rung addresses one element", () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[1]!);
groups[1]!.remove();
expect(selectorFor(selection)).toBeNull();
});
});
@@ -5,11 +5,21 @@
*/
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import type { DomEditSelection } from "./domEditing";
import { writeTargetSelector } from "../../hooks/gsapShared";
/**
* The selector the overlay both MEASURES the element by and authors a new
* motion path against.
*
* Both halves need exactly one element. The selection's own selector is a bare
* class for an id-less element, so a `.group` sibling read its home position off
* the FIRST sibling (skewing the destination the click computes) and then wrote
* `add-motion-path` onto all five. `writeTargetSelector` is the same one-element
* narrowing every other new-tween writer goes through; null means no such form
* exists, and the overlay hides "set destination" rather than write a wrong one.
*/
export function selectorFor(sel: DomEditSelection | null): string | null {
if (!sel) return null;
if (sel.id) return `#${CSS.escape(sel.id)}`;
return sel.selector ?? null;
return sel ? writeTargetSelector(sel) : null;
}
/** The animation whose path is editable on-canvas: literal, statically resolved,
+43 -10
View File
@@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { computeElementPercentage, idSelector } from "./gsapShared";
import { computeElementPercentage, idSelector, writeTargetSelector } from "./gsapShared";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
@@ -44,6 +44,23 @@ export interface GsapDragCommitCallbacks {
fetchAnimations?: () => Promise<GsapAnimation[]>;
}
/**
* The target for a tween these helpers are about to CREATE. Callers derive
* `selector` with `selectorFromSelection`, which hands back a bare class for an
* id-less element: authoring that widens a one-element drag/resize/rotate into a
* write over every sibling sharing the class. Retargets of an EXISTING tween
* must NOT come through here (they keep `anim.targetSelector`, so a tween the
* author aimed at a group stays aimed at it).
*
* Null means no one-element form exists, and every caller drops the commit
* rather than falling back to `selector` (see writeTargetSelector): the drag
* reverts on the next reload, which is recoverable, where a `.group` write is
* not.
*/
function newTweenTarget(selection: DomEditSelection): string | null {
return writeTargetSelector(selection);
}
// Re-export for backward compatibility with existing imports.
export function computeCurrentPercentage(
selection: DomEditSelection,
@@ -65,17 +82,18 @@ export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
async function replaceKeyframedPositionHold(
selection: DomEditSelection,
selector: string,
existingSet: GsapAnimation,
properties: { x: number; y: number },
commitMutation: GsapDragCommitCallbacks["commitMutation"],
): Promise<void> {
const target = newTweenTarget(selection);
if (!target) return;
const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
await commit(
selection,
{
type: "add",
targetSelector: selector,
targetSelector: target,
method: "set",
position: 0,
properties,
@@ -174,7 +192,6 @@ export async function commitStaticGsapPosition(
// least one hold on disk, then delete the corrupt tween in one transaction.
await replaceKeyframedPositionHold(
selection,
selector,
existingSet,
{ x: newX, y: newY },
callbacks.commitMutation,
@@ -199,11 +216,15 @@ export async function commitStaticGsapPosition(
}
// New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with
// an instant patch so the first nudge shows immediately (no soft-reload flash).
// The patch reuses the WRITTEN target so the runtime moves exactly the element
// the source write names.
const target = newTweenTarget(selection);
if (!target) return;
await callbacks.commitMutation(
selection,
{
type: "add",
targetSelector: selector,
targetSelector: target,
method: "set",
position: 0,
properties: { x: newX, y: newY },
@@ -212,7 +233,10 @@ export async function commitStaticGsapPosition(
{
label: "Move layer",
softReload: true,
instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
instantPatch: {
selector: target,
change: { kind: "global-set", props: { x: newX, y: newY } },
},
},
);
}
@@ -252,11 +276,13 @@ export async function commitStaticGsapRotation(
return;
}
// New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
const target = newTweenTarget(selection);
if (!target) return;
await callbacks.commitMutation(
selection,
{
type: "add",
targetSelector: selector,
targetSelector: target,
method: "set",
position: 0,
properties: { rotation: newRotation },
@@ -265,7 +291,10 @@ export async function commitStaticGsapRotation(
{
label: "Rotate layer",
softReload: true,
instantPatch: { selector, change: { kind: "global-set", props: { rotation: newRotation } } },
instantPatch: {
selector: target,
change: { kind: "global-set", props: { rotation: newRotation } },
},
},
);
}
@@ -301,11 +330,13 @@ export async function commitStaticGsapSize(
);
return;
}
const target = newTweenTarget(selection);
if (!target) return;
await callbacks.commitMutation(
selection,
{
type: "add",
targetSelector: selector,
targetSelector: target,
method: "set",
position: 0,
properties: { width, height },
@@ -389,11 +420,13 @@ export async function commitKeyframedSizeFromResize(
// transport applies both in one ordered batch; a plain commit fallback keeps the
// same recoverable ordering. Only the transaction's result triggers the reload.
const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
const target = newTweenTarget(selection);
if (!target) return false;
await callbacks.commitMutation(
selection,
{
type: "add-with-keyframes",
targetSelector: selector,
targetSelector: target,
position: roundTo3(ts),
duration: roundTo3(td),
keyframes,
@@ -1,6 +1,6 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
import { isInstantHold } from "./gsapShared";
import { isInstantHold, tweenTargetsElement } from "./gsapShared";
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
interface UpdatePropertyMutation {
@@ -34,12 +34,13 @@ export function setPatchFromUpdateProperty(
function findPositionSetAnimation(
animations: GsapAnimation[],
selector: string,
element?: Element | null,
): GsapAnimation | null {
return (
animations.find(
(a) =>
a.method === "set" &&
a.targetSelector === selector &&
tweenTargetsElement(a.targetSelector, selector, element) &&
("x" in a.properties || "y" in a.properties),
) ?? null
);
@@ -61,12 +62,16 @@ function findPositionSetAnimation(
export function findExistingPositionWrite(
animations: GsapAnimation[],
selector: string,
element?: Element | null,
): GsapAnimation | null {
const set = findPositionSetAnimation(animations, selector);
const set = findPositionSetAnimation(animations, selector, element);
if (set) return set;
return (
animations.find(
(a) => a.targetSelector === selector && a.propertyGroup === "position" && isInstantHold(a),
(a) =>
tweenTargetsElement(a.targetSelector, selector, element) &&
a.propertyGroup === "position" &&
isInstantHold(a),
) ?? null
);
}
@@ -74,10 +79,14 @@ export function findExistingPositionWrite(
export function findRotationSetAnimation(
animations: GsapAnimation[],
selector: string,
element?: Element | null,
): GsapAnimation | null {
return (
animations.find(
(a) => isInstantHold(a) && a.targetSelector === selector && "rotation" in a.properties,
(a) =>
isInstantHold(a) &&
tweenTargetsElement(a.targetSelector, selector, element) &&
"rotation" in a.properties,
) ?? null
);
}
@@ -85,12 +94,13 @@ export function findRotationSetAnimation(
export function findSizeSetAnimation(
animations: GsapAnimation[],
selector: string,
element?: Element | null,
): GsapAnimation | null {
return (
animations.find(
(a) =>
isInstantHold(a) &&
a.targetSelector === selector &&
tweenTargetsElement(a.targetSelector, selector, element) &&
("width" in a.properties || "height" in a.properties),
) ?? null
);
@@ -63,16 +63,18 @@ export async function commitKeyframeAtTimeImpl(
},
);
} else {
// Null means the live DOM could not prove any one-element form. Falling
// back to the author's own selector would write the group-collapsing
// target this narrowing exists to prevent, so the keyframe is dropped
// instead (see writeTargetSelector).
const target = writeTargetSelector(selection);
if (!target) return;
const defaultDuration = 0.5;
await commitMutation(
selection,
{
type: "add-with-keyframes" as const,
// Null here means the live DOM could not prove any one-element form.
// This branch has no graceful no-op to fall to, so it takes the
// author's own selector, group collapse and all, over dropping the
// keyframe the user just asked for.
targetSelector: writeTargetSelector(selection) ?? selector,
targetSelector: target,
position: absoluteTime,
duration: defaultDuration,
keyframes: [
@@ -79,7 +79,7 @@ export async function tryGsapResizeIntercept(
if (!anim || isInstantHold(anim)) {
const sel = selectorFromSelection(selection);
if (!sel) return false;
const sizeSet = anim ?? findSizeSetAnimation(animations, sel);
const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
// If the element is animated (has a real tween, not just a static size
// hold), keyframe the size at the playhead so other keyframes keep theirs —
@@ -242,7 +242,7 @@ export async function tryGsapResizeIntercept(
const currentAnimations = fetchFallbackAnimations
? await fetchFallbackAnimations()
: (resolved?.animations ?? animations);
const existingSet = findExistingPositionWrite(currentAnimations, selector);
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
// Delta chosen so the drag-path math composes back to exactly `corrected`
// (no drag scratch attrs exist during a resize, so base = gsapPos).
await commitStaticGsapPosition(
@@ -213,7 +213,7 @@ export async function tryGsapDragIntercept(
const existingSet =
posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
? posAnim
: findExistingPositionWrite(resolvedAnimations, selector);
: findExistingPositionWrite(resolvedAnimations, selector, selection.element);
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
@@ -301,7 +301,8 @@ export async function tryGsapRotationIntercept(
// rotation set in place, else add a new one. This replaces the old
// `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
if (!anim || isInstantHold(anim)) {
const existingSet = anim ?? findRotationSetAnimation(resolvedAnimations, selector);
const existingSet =
anim ?? findRotationSetAnimation(resolvedAnimations, selector, selection.element);
await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
@@ -2,17 +2,32 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu
import type { DomEditSelection } from "../components/editor/domEditingTypes";
export { PROPERTY_DEFAULTS } from "./gsapShared";
import { idSelector } from "./gsapShared";
import { idSelector, matchesExactlyOne } from "./gsapShared";
/**
* The selector to author a NEW tween against, minting an id on the element when
* it has no address of its own.
*
* `selection.selector` is only usable when it addresses ONE element:
* `buildStableSelector` hands back a bare class for an id-less element, so
* returning it unconditionally aimed "add animation" at every sibling sharing
* the class (the attribution blow-up that collapsed the timeline to one row,
* see writeTargetSelector). A non-unique selector falls through to the id mint
* below, which is the stronger fix here than a structural path: the id it writes
* back to the source also makes every later lookup for this element exact.
*/
export function ensureElementAddressable(selection: DomEditSelection): {
selector: string;
autoId?: string;
} {
if (selection.id) return { selector: idSelector(selection.id) };
if (selection.selector) return { selector: selection.selector };
const el = selection.element;
const doc = el.ownerDocument;
if (selection.selector && matchesExactlyOne(doc, selection.selector, el)) {
return { selector: selection.selector };
}
const tag = el.tagName.toLowerCase();
let id = tag;
let n = 1;
+39 -6
View File
@@ -186,7 +186,12 @@ function attributeSelector(name: string, value: string): string {
return `[${name}="${value.replace(/(["\\])/g, "\\$1")}"]`;
}
function matchesExactlyOne(doc: Document, selector: string, element: Element): boolean {
/**
* Whether `selector` addresses `element` AND NOTHING ELSE. The single test for
* "this string is safe to author a new tween against": a selector that also
* hits siblings writes a tween that animates all of them.
*/
export function matchesExactlyOne(doc: Document, selector: string, element: Element): boolean {
try {
const matches = doc.querySelectorAll(selector);
return matches.length === 1 && matches[0] === element;
@@ -246,11 +251,12 @@ function structuralSelector(element: Element): string | null {
* against, a failed structural walk (detached between select and commit, a
* shadow-root boundary, a chain that no longer re-resolves) IS that evidence,
* so returning the bare selector anyway would hand back the exact input this
* function exists to replace. Callers decide what to do with the null: the
* add-keyframe path has a graceful no-animation fallback, and the paths that
* cannot afford to drop a user edit opt back in with `?? selectorFromSelection`
* where the trade is visible. The bare selector comes back only with no DOM to
* disambiguate against, where refusing would be guessing rather than knowing.
* function exists to replace. Every caller treats the null as "do not author
* this tween": falling back to the selection's own selector would write the
* group-collapsing target this function exists to prevent, and a gesture that
* does not persist reverts visibly on the next reload, where a tween silently
* aimed at five elements does not. The bare selector comes back only with no DOM
* to disambiguate against, where refusing would be guessing rather than knowing.
*/
export function writeTargetSelector(selection: DomEditSelection): string | null {
if (selection.id) return idSelector(selection.id);
@@ -286,6 +292,33 @@ export function existingTweenTargetSelector(
return selectorFromSelection(selection);
}
/**
* The read half of {@link writeTargetSelector}: does an already-authored tween
* write THIS element?
*
* String equality against `selectorFromSelection` alone is not enough once new
* tweens are authored with a narrowed one-element selector: the next edit would
* miss the write it just made and append a second, conflicting one. Falling back
* to the live DOM keeps the pair consistent.
*
* That fallback is `matchesExactlyOne`, not a bare `element.matches`. A target
* the element merely shares with its siblings is a GROUP tween, and the callers
* here MUTATE what they find: an individual nudge on one of five `.group`
* siblings would rewrite the group's own tween and move all five. Only the
* selection that IS the group (string equality above, where the author selected
* `.group` itself) may edit it; every other element authors its own write.
*/
export function tweenTargetsElement(
targetSelector: string,
selector: string,
element: Element | null | undefined,
): boolean {
if (targetSelector === selector) return true;
const doc = element?.ownerDocument;
if (!element || !doc) return false;
return matchesExactlyOne(doc, targetSelector, element);
}
// ── Percentage computation ────────────────────────────────────────────────────
/**
@@ -4,7 +4,7 @@ import { parseGsapScript } from "@hyperframes/core/gsap-parser";
import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
import { resolveSelectorElementIds, writeTargetSelector } from "./gsapShared";
import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
import { promoteSetToKeyframes } from "./useEnableKeyframes";
@@ -244,3 +244,46 @@ describe("commitKeyframeAtTimeImpl — new-tween target", () => {
expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
});
});
describe("tweenTargetsElement", () => {
it("matches a tween narrowed to this element by a selector the selection does not use", () => {
const groups = mountGroupSiblings();
groups[2]!.id = "narrowed";
// The write half authored "#narrowed"; the read half still has ".group".
expect(tweenTargetsElement("#narrowed", ".group", groups[2])).toBe(true);
});
it("does not hand an individual element the group tween it merely inherits", () => {
const groups = mountGroupSiblings();
// Selecting one sibling by its own address must not let an edit mutate the
// ".group" tween: that write moves all five, not the one being nudged.
expect(tweenTargetsElement(".group", "#scene > div:nth-child(3)", groups[2])).toBe(false);
});
it("still edits a group tween when the group itself is the selection", () => {
const groups = mountGroupSiblings();
expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
});
it("does not match a target that is not a selector matches() understands", () => {
const groups = mountGroupSiblings();
expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
});
});
describe("commitKeyframeAtTimeImpl — no one-element target", () => {
it("drops the keyframe rather than authoring the bare class", async () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[3]!);
groups[3]!.remove();
const commitMutation = vi.fn(async () => undefined);
await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
expect(commitMutation).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,364 @@
// @vitest-environment jsdom
/**
* Sibling writers of the "add keyframe at playhead" path fixed in
* gsapShared.writeTarget.test.ts. Every mutation that authors a NEW tween must
* address ONE element; a bare class attributes the write to every sibling that
* shares it, which is what collapsed the timeline to a single row.
*
* Same round trip as the U3 test: author through the real writer, re-parse with
* the real parser, resolve through the very function that feeds the keyframe
* cache and the lanes.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseGsapScript } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
import { resolveSelectorElementIds } from "./gsapShared";
import { ensureElementAddressable } from "./gsapScriptCommitHelpers";
import {
commitStaticGsapPosition,
commitStaticGsapRotation,
commitStaticGsapSize,
commitKeyframedSizeFromResize,
commitWholePathOffset,
findExistingPositionWrite,
} from "./gsapDragCommit";
import { promoteSetToKeyframes } from "./useEnableKeyframes";
afterEach(() => {
document.body.innerHTML = "";
});
function selectionFor(el: HTMLElement): DomEditSelection {
const selector = buildStableSelector(el);
return {
element: el,
id: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}
/** Five class-only siblings, each with an id so attribution is nameable. */
function mountGroupSiblings(): HTMLElement[] {
document.body.innerHTML = `
<div id="scene" class="clip" data-start="0" data-duration="2">
<div class="group" id="group-0"></div>
<div class="group" id="group-1"></div>
<div class="group" id="group-2"></div>
<div class="group" id="group-3"></div>
<div class="group" id="group-4"></div>
</div>
`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}
/**
* The selection production hands these writers: the element HAS an id in the
* DOM (so resolveSelectorElementIds can name it) but the SELECTION carries none,
* which is the id-less shape buildStableSelector answers with a bare class.
*/
function classOnlySelection(el: HTMLElement): DomEditSelection {
return { ...selectionFor(el), id: undefined, selector: ".group" } as DomEditSelection;
}
/** The elements a written targetSelector actually attributes the tween to. */
function attributedTo(targetSelector: string): string[] {
return resolveSelectorElementIds(targetSelector, document);
}
function recorder() {
const mutations: Array<Record<string, unknown>> = [];
const commitMutation = vi.fn(async (_sel, mutation, _opts) => {
mutations.push(mutation as Record<string, unknown>);
});
return { mutations, callbacks: { commitMutation } as never };
}
/** Every `targetSelector` a run of mutations wrote. */
function writtenTargets(mutations: Array<Record<string, unknown>>): string[] {
return mutations.map((m) => m.targetSelector).filter((s): s is string => typeof s === "string");
}
describe("ensureElementAddressable — add-animation button", () => {
it("addresses one element when the selection's only identity is a shared class", () => {
// Truly id-less siblings: the shape production reaches this path with (an
// element WITH an id never gets here, selection.id short-circuits above).
document.body.innerHTML = `
<div id="scene" class="clip">
<div class="group"></div><div class="group"></div><div class="group"></div>
</div>
`;
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
const selection = selectionFor(el);
expect(selection.selector).toBe(".group");
const { selector, autoId } = ensureElementAddressable(selection);
expect(autoId).toBeTruthy();
expect(document.querySelectorAll(selector)).toHaveLength(1);
expect(document.querySelector(selector)).toBe(el);
expect(attributedTo(selector)).toEqual([autoId]);
});
it("keeps a unique #id target", () => {
document.body.innerHTML = `<div id="box" class="card"></div>`;
const el = document.querySelector<HTMLElement>("#box")!;
expect(ensureElementAddressable(selectionFor(el)).selector).toBe("#box");
});
it("keeps an already-unique class selector as authored", () => {
document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
const el = document.querySelector<HTMLElement>(".header")!;
expect(ensureElementAddressable(selectionFor(el)).selector).toBe(".header");
});
it("still mints an id when there is no live element to disambiguate against", () => {
document.body.innerHTML = `<div id="scene"><div></div></div>`;
const el = document.querySelector<HTMLElement>("#scene > div")!;
const selection = { ...selectionFor(el), selector: undefined } as DomEditSelection;
const { selector, autoId } = ensureElementAddressable(selection);
expect(autoId).toBe("div");
expect(selector).toBe("#div");
});
});
describe("gsapDragCommit — new-tween targets", () => {
it("commitStaticGsapPosition authors the new set against one element", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
await commitStaticGsapPosition(
classOnlySelection(groups[2]!),
{ x: 10, y: 10 },
{ x: 0, y: 0 },
".group",
null,
callbacks,
);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-2"]);
});
it("commitStaticGsapRotation authors the new set against one element", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
await commitStaticGsapRotation(classOnlySelection(groups[1]!), 42, ".group", null, callbacks);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-1"]);
});
it("commitStaticGsapSize authors the new set against one element", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
await commitStaticGsapSize(
classOnlySelection(groups[4]!),
{ width: 100, height: 50 },
".group",
null,
callbacks,
);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-4"]);
});
it("commitKeyframedSizeFromResize authors the new keyframe tween against one element", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
const animatedTween = {
id: "t1",
targetSelector: ".group",
method: "to",
properties: {},
resolvedStart: 0,
duration: 2,
keyframes: { keyframes: [{ percentage: 0, properties: { x: 0 } }] },
} as unknown as GsapAnimation;
const handled = await commitKeyframedSizeFromResize(
classOnlySelection(groups[3]!),
{ width: 80, height: 40 },
".group",
null,
animatedTween,
callbacks,
);
expect(handled).toBe(true);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-3"]);
});
it("commitStaticGsapPosition replaces a corrupt keyframed hold against one element", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
const corruptHold = {
id: "hold-1",
targetSelector: ".group",
method: "to",
properties: {},
duration: 0,
keyframes: { keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }] },
} as unknown as GsapAnimation;
await commitStaticGsapPosition(
classOnlySelection(groups[0]!),
{ x: 5, y: 5 },
{ x: 0, y: 0 },
".group",
corruptHold,
callbacks,
);
expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-0"]);
});
});
describe("gsapDragCommit — retargeting an existing tween is left alone", () => {
it("commitWholePathOffset keeps the tween's own group target", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
const groupTween = {
id: "t-group",
targetSelector: ".group",
method: "to",
properties: { x: 100 },
resolvedStart: 0,
duration: 2,
} as unknown as GsapAnimation;
await commitWholePathOffset(
classOnlySelection(groups[2]!),
groupTween,
{ x: 10, y: 0 },
{ x: 0, y: 0 },
null,
".group",
callbacks,
);
// A tween the author aimed at all five siblings must STAY aimed at all five:
// narrowing it here would silently drop four elements out of the animation.
expect(writtenTargets(mutations)[0]).toBe(".group");
expect(attributedTo(writtenTargets(mutations)[0]!)).toHaveLength(5);
});
});
/**
* The write selector and the "is there already a write for this element?"
* lookup are two halves of one contract. Narrowing only the write half would
* make the next nudge miss its own previous write and append a second one the
* duplicate-position-write bug findExistingPositionWrite exists to prevent.
*/
describe("a re-nudge updates its own previous write instead of stacking a second one", () => {
it("finds the write the first nudge authored", async () => {
const groups = mountGroupSiblings();
const selection = classOnlySelection(groups[2]!);
const first = recorder();
await commitStaticGsapPosition(
selection,
{ x: 10, y: 10 },
{ x: 0, y: 0 },
".group",
null,
first.callbacks,
);
const written = writtenTargets(first.mutations)[0]!;
// Read the first write back the way the next drag does: parse the source,
// then run the production lookup for this element's position write.
const script = `
const tl = gsap.timeline({ paused: true });
gsap.set(${JSON.stringify(written)}, { x: 10, y: 10 });
`.trim();
const animations = parseGsapScript(script).animations;
const existing = findExistingPositionWrite(animations, ".group", selection.element);
expect(existing).toBeTruthy();
const second = recorder();
await commitStaticGsapPosition(
selection,
{ x: 5, y: 0 },
{ x: 10, y: 10 },
".group",
existing,
second.callbacks,
);
expect(second.mutations[0]!.type).toBe("update-properties");
expect(second.mutations[0]!.animationId).toBe(existing!.id);
});
});
/**
* Candidate C. Every `replace-with-keyframes` in useEnableKeyframes names an
* `animationId` parsed out of the CURRENT SOURCE (the anims list comes from
* tryFetchAnimationsForElement), so each one rewrites a tween the author already
* has. Narrowing those to one element would silently drop the other four
* siblings out of an animation that was aimed at the group on purpose.
*/
describe("useEnableKeyframes — rewriting an existing tween keeps its group target", () => {
it("promoteSetToKeyframes leaves a group-authored set aimed at the group", async () => {
const groups = mountGroupSiblings();
const mutations: Array<Record<string, unknown>> = [];
const setAnim = {
id: "set-group",
targetSelector: ".group",
method: "set",
properties: { x: 0, y: 0 },
resolvedStart: 0,
duration: 0,
} as unknown as GsapAnimation;
const session = {
commitMutation: async (mutation: Record<string, unknown>) => {
mutations.push(mutation);
},
handleGsapRemoveKeyframe: vi.fn(),
};
// Playhead at the set: the branch that replaces it with a single keyframe,
// which can source its value from the set itself (no live iframe needed).
await promoteSetToKeyframes(session as never, classOnlySelection(groups[2]!), setAnim, 0, null);
expect(mutations[0]!.type).toBe("replace-with-keyframes");
expect(mutations[0]!.animationId).toBe("set-group");
expect(mutations[0]!.targetSelector).toBe(".group");
expect(attributedTo(mutations[0]!.targetSelector as string)).toHaveLength(5);
});
});
describe("the written selector survives the real writer and parser", () => {
it("re-parses to a tween attributed to the one element it targeted", async () => {
const groups = mountGroupSiblings();
const { mutations, callbacks } = recorder();
await commitStaticGsapPosition(
classOnlySelection(groups[2]!),
{ x: 10, y: 10 },
{ x: 0, y: 0 },
".group",
null,
callbacks,
);
const written = writtenTargets(mutations)[0]!;
const script = `
const tl = gsap.timeline({ paused: true });
gsap.set(${JSON.stringify(written)}, { x: 10, y: 10 });
`.trim();
const parsed = parseGsapScript(script).animations.find((a) => a.targetSelector === written);
expect(parsed).toBeTruthy();
expect(resolveSelectorElementIds(parsed!.targetSelector, document)).toEqual(["group-2"]);
});
});
@@ -0,0 +1,255 @@
// @vitest-environment jsdom
/**
* Hook-level siblings of newTweenTarget.test.ts: the property panel and the
* gesture recorder also author NEW tweens, so they face the same bare-class
* blow-up. Driven through the real hooks so the classification under test
* (new tween vs retarget of an existing one) is the production one.
*/
import React, { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { resolveSelectorElementIds } from "./gsapShared";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
import { useGestureCommit } from "./useGestureCommit";
import { mountReactHarness, installReactActEnvironment } from "./domSelectionTestHarness";
installReactActEnvironment();
const frozenSamples = [
{ time: 0, properties: { x: 0, y: 0 } },
{ time: 0.5, properties: { x: 60, y: 10 } },
{ time: 1, properties: { x: 120, y: 40 } },
];
vi.mock("./useGestureRecording", () => ({
useGestureRecording: () => ({
startRecording: vi.fn(),
stopRecording: () => frozenSamples,
clearSamples: vi.fn(),
samples: frozenSamples,
}),
}));
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 });
});
function mountGroupSiblings(): HTMLElement[] {
document.body.innerHTML = `
<div id="scene" class="clip" data-start="0" data-duration="2">
<div class="group" id="group-0"></div>
<div class="group" id="group-1"></div>
<div class="group" id="group-2"></div>
<div class="group" id="group-3"></div>
<div class="group" id="group-4"></div>
</div>
`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}
/** The id-less shape buildStableSelector answers with a bare class. */
function classOnlySelection(el: HTMLElement): DomEditSelection {
return {
element: el,
id: undefined,
selector: ".group",
selectorIndex: Array.prototype.indexOf.call(el.parentElement!.children, el),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}
function attributedTo(targetSelector: unknown): string[] {
return resolveSelectorElementIds(String(targetSelector), document);
}
type Commit = (
selection: DomEditSelection,
props: Record<string, number | string>,
) => Promise<void>;
function renderPropertyCommit(
animations: GsapAnimation[],
mutations: Array<Record<string, unknown>>,
onReady: (commit: Commit) => void,
) {
function Harness() {
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: animations,
gsapCommitMutation: async (_sel: DomEditSelection, mutation: Record<string, unknown>) => {
mutations.push(mutation);
},
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
previewIframeRef: { current: null },
bumpGsapCache: vi.fn(),
} as never);
onReady(commitAnimatedProperties);
return null;
}
return mountReactHarness(<Harness />);
}
describe("useAnimatedPropertyCommit — new-tween targets", () => {
it("authors a new static set against one element", async () => {
const groups = mountGroupSiblings();
const mutations: Array<Record<string, unknown>> = [];
let commit: Commit | undefined;
const root = renderPropertyCommit([], mutations, (fn) => (commit = fn));
await act(async () => {
await commit!(classOnlySelection(groups[2]!), { rotationX: 30 });
});
const added = mutations.find((m) => m.type === "add");
expect(added).toBeTruthy();
expect(attributedTo(added!.targetSelector)).toEqual(["group-2"]);
act(() => root.unmount());
});
it("authors the first same-group keyframe tween against one element", async () => {
const groups = mountGroupSiblings();
// A position tween exists, so the element IS animated; a rotation edit has
// no same-group tween to join and must author a fresh one.
const positionTween = {
id: "t-pos",
targetSelector: ".group",
propertyGroup: "position",
method: "to",
properties: { x: 0 },
resolvedStart: 0,
duration: 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 50 } },
],
},
} as unknown as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit: Commit | undefined;
const root = renderPropertyCommit([positionTween], mutations, (fn) => (commit = fn));
await act(async () => {
await commit!(classOnlySelection(groups[1]!), { rotationY: 15 });
});
const added = mutations.find((m) => m.type === "add-with-keyframes");
expect(added).toBeTruthy();
expect(attributedTo(added!.targetSelector)).toEqual(["group-1"]);
act(() => root.unmount());
});
it("leaves an existing group tween aimed at its whole group", async () => {
const groups = mountGroupSiblings();
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
const groupTween = {
id: "t-pos",
targetSelector: ".group",
propertyGroup: "position",
method: "to",
properties: { x: 0 },
resolvedStart: 0,
duration: 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 50, y: 0 } },
],
},
} as unknown as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit: Commit | undefined;
const root = renderPropertyCommit([groupTween], mutations, (fn) => (commit = fn));
await act(async () => {
await commit!(classOnlySelection(groups[2]!), { x: 90 });
});
const replaced = mutations.find((m) => m.type === "replace-with-keyframes");
expect(replaced).toBeTruthy();
expect(replaced!.targetSelector).toBe(".group");
act(() => root.unmount());
});
});
function renderGestureCommit(
animations: GsapAnimation[],
mutations: Array<Record<string, unknown>>,
selection: DomEditSelection,
) {
let toggle: (() => void) | undefined;
function Harness() {
const { handleToggleRecording } = useGestureCommit({
domEditSessionRef: {
current: {
domEditSelection: selection,
selectedGsapAnimations: animations,
commitMutation: async (mutation: Record<string, unknown>) => {
mutations.push(mutation);
},
},
} as never,
previewIframeRef: { current: document.createElement("iframe") },
showToast: vi.fn(),
isGestureRecordingRef: { current: false },
});
toggle = handleToggleRecording;
return null;
}
const root = mountReactHarness(<Harness />);
return { root, toggle: () => toggle!() };
}
describe("useGestureCommit — new-tween targets", () => {
it("authors the recorded tween against one element", async () => {
const groups = mountGroupSiblings();
const mutations: Array<Record<string, unknown>> = [];
const { root, toggle } = renderGestureCommit([], mutations, classOnlySelection(groups[3]!));
act(() => toggle()); // start
await act(async () => {
toggle(); // stop + commit
await Promise.resolve();
});
const added = mutations.find((m) => m.type === "add-with-keyframes");
expect(added).toBeTruthy();
expect(attributedTo(added!.targetSelector)).toEqual(["group-3"]);
act(() => root.unmount());
});
it("leaves a merged existing group tween aimed at its whole group", async () => {
const groups = mountGroupSiblings();
const existing = {
id: "t-pos",
targetSelector: ".group",
propertyGroup: "position",
method: "to",
properties: { x: 0 },
resolvedStart: 0,
duration: 2,
keyframes: { keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }] },
} as unknown as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
const { root, toggle } = renderGestureCommit(
[existing],
mutations,
classOnlySelection(groups[3]!),
);
act(() => toggle());
await act(async () => {
toggle();
await Promise.resolve();
});
const replaced = mutations.find((m) => m.type === "replace-with-keyframes");
expect(replaced).toBeTruthy();
expect(replaced!.targetSelector).toBe(".group");
act(() => root.unmount());
});
});
@@ -14,7 +14,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
import type { SetPatchProps } from "./gsapRuntimePatch";
import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
import {
selectorFromSelection,
computeElementPercentage,
isInstantHold,
writeTargetSelector,
tweenTargetsElement,
} from "./gsapShared";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
@@ -185,7 +191,9 @@ async function commitStaticSet(
batch.push(entry);
byGroup.set(group, batch);
}
const staticWrites = animations.filter((a) => isInstantHold(a) && a.targetSelector === selector);
const staticWrites = animations.filter(
(a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
);
// Resolve every group's target BEFORE committing anything, and coalesce
// groups that land on the SAME write into one commit: the snapshot is captured
// once, so if two groups resolved to one legacy mixed write, a first
@@ -206,7 +214,7 @@ async function commitStaticSet(
}
// Fresh adds don't reshape existing sets, so their ids can't go stale.
for (const batch of newSetBatches) {
await addGlobalStaticSet(selection, batch, selector, commit);
await addGlobalStaticSet(selection, batch, commit);
}
}
@@ -236,18 +244,22 @@ function findGroupOwningStaticWrite(
async function addGlobalStaticSet(
selection: DomEditSelection,
batch: [string, number | string][],
selector: string,
commit: Commit,
): Promise<void> {
const numericProps: SetPatchProps = {};
for (const [k, v] of batch) {
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
}
// A brand-new write, so it must address ONE element: the selection's own
// selector is the bare class an id-less element yields, which would hold every
// sibling. No one-element form means no write at all (see writeTargetSelector).
const target = writeTargetSelector(selection);
if (!target) return;
await commit(
selection,
{
type: "add",
targetSelector: selector,
targetSelector: target,
method: "set",
position: 0,
properties: Object.fromEntries(batch),
@@ -259,7 +271,7 @@ async function addGlobalStaticSet(
...(Object.keys(numericProps).length > 0
? {
instantPatch: {
selector,
selector: target,
change: { kind: "global-set" as const, props: numericProps },
},
}
@@ -482,7 +494,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
// contaminating a foreign-group tween. Mirror an existing keyframed tween's
// time range so the new group animates over the same span. The 0% baseline is
// an `_auto` endpoint so it tracks the nearest keyframe as you add more.
if (selector) {
// A fresh tween, so its target must address ONE element; with no
// one-element form the edit is dropped rather than written onto every
// class sibling (see writeTargetSelector).
const newTweenTarget = writeTargetSelector(selection);
if (selector && newTweenTarget) {
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -503,7 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
selection,
{
type: "add-with-keyframes",
targetSelector: selector,
targetSelector: newTweenTarget,
position: roundTo3(tStart),
duration: roundTo3(tDur),
keyframes,
+21 -4
View File
@@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
import { roundTo3 } from "../utils/rounding";
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
import { isInstantHold, idSelector } from "./gsapShared";
import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
type RecordedKeyframe = {
percentage: number;
@@ -168,11 +168,24 @@ export function useGestureCommit({
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
}
// Two different jobs, two different selectors. `selector` is the string an
// ALREADY-AUTHORED tween is matched against (and retargeted with, so a
// tween aimed at a whole group stays aimed at it). `writeSelector` is what
// a NEW tween is authored with: the bare class the id-less case yields here
// would record the gesture onto every sibling sharing it.
const selector = sel.id ? idSelector(sel.id) : sel.selector;
if (!selector) {
showToast("Cannot save — element has no selector", "error");
return;
}
// A recorded gesture becomes a NEW tween, so its target must address one
// element; the selection's own selector would record the motion onto
// every sibling sharing its class (see writeTargetSelector).
const writeSelector = writeTargetSelector(sel);
if (!writeSelector) {
showToast("Cannot save: element has no unique selector", "error");
return;
}
if (liveSession.commitMutation) {
const recStart = recordingStartTimeRef.current;
const rawKeyframes = sortedPcts.map((pct) => ({
@@ -186,7 +199,11 @@ export function useGestureCommit({
);
const allAnims = liveSession.selectedGsapAnimations ?? [];
const existingPositionTween = hasPositionProps
? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
? allAnims.find(
(a) =>
a.propertyGroup === "position" &&
tweenTargetsElement(a.targetSelector, selector, sel.element),
)
: undefined;
if (existingPositionTween) {
if (isInstantHold(existingPositionTween)) {
@@ -261,7 +278,7 @@ export function useGestureCommit({
await liveSession.commitMutation(
{
type: "add-with-keyframes",
targetSelector: selector,
targetSelector: writeSelector,
position: roundTo3(recStart),
duration: roundTo3(duration),
keyframes: groupKfs,
@@ -287,7 +304,7 @@ export function useGestureCommit({
await liveSession.commitMutation(
{
type: "add-with-keyframes",
targetSelector: selector,
targetSelector: writeSelector,
position: roundTo3(recStart),
duration: roundTo3(duration),
keyframes: groupKfs,